Troubleshooting
A consolidated index of the most common failure modes, with pointers to the guide that explains the underlying mechanic. This page doesn't go deep — it's a decision tree. Follow the cross-links for root-cause explanations.
Projection doesn't appear
The host is empty and there are no errors.
- The host element has no size. Virtual Frame fills the host's box. A host with
height: 0or no layout-participating size renders empty. Set explicitwidthandheight, or place it in a flex/grid cell. - The source iframe's
srcdidn't resolve. The hidden iframe sits atposition: fixed; left: -9999px— open it in devtools and confirm its document loaded (no 404, no redirect loop). - You're reading the projection synchronously. Projection kicks off from the constructor but the first content arrives asynchronously after the iframe finishes loading. Wait for
vf.isInitializedoriframe.onloadbefore asserting on DOM. In tests, usefindBy…/waitFor. - For cross-origin sources, the bridge didn't run. Open the source document's own devtools (load it directly in a new tab) and confirm
virtual-frame/bridgeactually executes. If you seevf:readyfiring forever in the remote console, the host never acknowledged — see Cross-Origin. selectormatched nothing. Projection stays empty until a match appears, then updates live. See Selector → No-match behavior.
Styles look wrong
Content is there but unstyled, or styled the wrong way.
- Host styles bleed into the projection. You forgot
isolate. Addisolate="open"— that's the default for all framework wrappers and what SSR requires. - First paint looks wrong, hydration fixes it. A
<link rel="stylesheet">wasn't inlined server-side. UsefetchVirtualFrame(notrenderVirtualFrameon a pre-fetched string), which fetches linked stylesheets server-side. See SSR → Common issues. - Host fonts don't render inside the projection. Fonts declared only in the host's stylesheet don't cross into a closed shadow tree. Declare fonts in the source document, or load them from the top-level page where both sides inherit them. See Shadow DOM →
@font-facepromotion. - CSS custom properties from a host theme don't apply. Custom properties inherit through the shadow boundary only when set on a host-side ancestor. Set them on the
<virtual-frame>element itself, on:hostinside the shadow, or on the host page'shtml/:root. height: 100vhinside the projection fills the browser, not the host element. Expected: only width viewport units (vw/svw/dvw/lvw) are rewritten tocqw. Height units are intentionally left alone because the host doesn't usecontainer-type: size. If the projection needs to fit the host exactly, give the host an explicit height and let the source use percentages. See Shadow DOM → Viewport units.
Interactivity is broken
Events fire but don't do what you expect.
event.isTrustedchecks fail. Replayed events (cross-origin) are synthetic and never trusted. Operations that require a trusted event — clipboard writes, fullscreen, autoplay unlock — won't work from a replayed event. Adjust the remote listener or live with the limitation.- Framework router classifies navigation as external. The env shim keeps
location.originpointing at the host for same-origin consistency. If you see full-page reloads instead of client-side nav, verify that the remote framework readswindow.location(notdocument.baseURI) for its router, and that you've setproxycorrectly if you're doing host-origin API calls. - Checkbox or radio clicks don't toggle
checked. The bridge handles this by calling nativeelement.click()rather than dispatching a synthetic event, because synthetic clicks don't toggle state. If you're seeing this fail, confirm you're on a current bridge version. - React
<input>state drifts. The bridge usesHTMLInputElement.prototype's nativevaluesetter descriptor to punch through React's overridden setter. If this isn't working, confirm the bridge is current and that the input isn't being re-rendered underneath the change. - Form input doesn't sync. Same-origin is automatic via MutationObserver; cross-origin routes through
vf:inputmessages. Add a temporarywindow.addEventListener("message", console.log)in the remote to confirm messages are arriving.
Cross-origin fails silently
- No snapshot arrives. The bridge loaded but never acknowledged. Look for
vf:readyrepeating on an interval in the remote console — if you see it, the host'svf:ackisn't coming through. Check that your host is actually mounting the<virtual-frame>element and that CSP isn't dropping thepostMessage. - Import the bridge before your framework runtime. If your framework clobbers
fetch,history, or prototype listeners before the bridge patches them, cross-origin breaks in subtle ways. - CSP blocks the bridge. Host:
frame-srcmust allow the remote origin. Remote:script-srcmust allow the bridge URL if it's loaded from a CDN. Look forRefused to loadin both consoles. - Multiple
<virtual-frame>elements pointed at the same remote. Works out of the box via the custom element's hidden-iframe ref-counting. If you're constructingVirtualFrameinstances directly, you need to share the iframe yourself — a single bridge can only broadcast towindow.parent. See Cross-Origin → Channel IDs and multiple hosts. - Channel crosstalk. Each
createBridge()generates a random channel id. If you passed an explicitchannelstring, make sure it's unique per bridge instance — two bridges on the same channel will deliver messages to both hosts.
proxy doesn't seem to work
- The core
VirtualFrameclass doesn't acceptproxy. It's a property of the env shim applied by the<virtual-frame>custom element and the meta-framework packages (@virtual-frame/next,-sveltekit,-nuxt, etc.). The client-only framework packages (@virtual-frame/react,-vue,-svelte,-solid,-angular) don't expose it either — use the SSR wrapper if you need it. - The host server isn't rewriting the proxy path.
proxy="/proxy/remote"requires the host to implement/proxy/remote/:path*→https://remote.example.com/:path*. Without the server rewrite, requests return 404. - Paths leak through without rewriting. The shim patches
fetch,XMLHttpRequest,URL,history, andlocation— but notWebSocketand not dynamicimport()of absolute cross-origin URLs. Keep realtime sockets and module imports at the remote origin, or proxy them at the network layer. See Cross-Origin → The env shim and theproxyoption.
Selector doesn't match what I expected
- Multiple elements match but only one shows. Expected —
selectorusesquerySelector(first-match) semantics, notquerySelectorAll. Use a more specific selector, or use multiple<virtual-frame>elements. See Selector → How matching works. - Match is gone but old content lingers briefly. Expected — the mirror freezes with previous content while watching for a replacement match, rather than flashing empty. If a replacement appears, projection resumes.
- Selector matches on the client but not at SSR time. Server-side matching runs against the fetched HTML string; if the target only mounts during hydration, SSR falls back to rendering the full body and the client re-evaluates on mount. Not a bug; pick a selector stable in server output or accept the fallback.
SSR-specific
selectormatched nothing at render time. Virtual Frame renders the full body as fallback and logs a warning. See Selector → SSR behavior.- Hydration mismatch between server and client HTML. Ensure you're using the same
isolateandselectoroptions on both sides, and thatproxyis aligned. Mismatched options produce different shadow templates. frame.htmlis enormous. Full-page SSR inlines all stylesheets and the whole body. Trim withselectoror skip SSR on low-priority pages.
Streaming / canvas
- Cross-origin projection is choppy. Cross-origin defaults to ~5 FPS (200 ms interval); there is no smooth-rAF equivalent cross-origin because per-frame data-URL encoding would saturate
postMessage. SetstreamingFps: 30(or higher) explicitly. See Streaming FPS → Default behavior. - Canvas shows a stale frame. The source canvas only redraws on events; smooth mode rAF ticks faster than the source is drawing. Set
streamingFpsto a polled interval so captures happen regardless. - Canvas is CORS-tainted. A canvas that has drawn a cross-origin image without CORS headers throws when you try to read pixels; the bridge silently drops the error and the projection doesn't update. Fix the image's CORS headers at the source.
- CPU spikes with many canvases. Smooth mode captures every canvas per frame. Cap globally (
streamingFps: 30) or use per-selector rules — remember that selectors match in declaration order, first-wins, not by specificity. - Video audio plays from the source. Expected — audio stays in the source document; the projected video is muted. Unmute / mute in the source.
- Per-selector FPS rule doesn't apply. Keys are matched via
Element.matchesin object-declaration order. Put more-specific keys first ({ ".preview": 5, canvas: 30 }, not the other way around).
Shared store
- State isn't syncing between host and remote. Check that both sides hold the same store reference (host) or open the singleton (remote). For manual transports, verify
handle.onOperationcallbacks on one side are reachinghandle.applyon the other — see Store → Custom transport. - Concurrent writes "lose" one value. Expected under last-writer-wins — the newer
tswins. For stronger semantics (CRDTs, counters), layer your logic on top of the raw store. - Mutation didn't trigger a re-render. Framework subscriptions batch at microtask boundaries. Multiple writes in the same task coalesce into one notification — this is intentional.
Development-only quirks
- Vite dev serves stylesheets as JS modules. SSR extracts CSS from
__vite__cssmarkers automatically. If you're building a custom fetcher, replicate that extraction. - Fast-refresh in the source iframe loses projection. Expected when the source document fully reloads — projection re-initializes on the next
loadevent. Navigation inside the iframe (SPA route change) keeps projection alive. - Devtools Elements panel doesn't show the projected DOM. Expand the host element's
#shadow-root (open)node — projected content lives inside the shadow root, not the light DOM. In closed mode, the panel hides it entirely; usevf.getShadowRoot()in the console to reach it.
Scroll sync looks off
- Projection scrolls but source doesn't, or vice versa. Scroll sync is bidirectional and uses normalized percentages (0–1). If only one side scrolls, check that both host and remote are receiving the relevant
vf:scroll/vf:scrollUpdatemessages. - Scroll position drifts on dynamic content. Expected — percentages survive layout changes, so a 50% position stays at 50% as content grows. If you want pixel-exact sync, you need to observe content size on both sides and translate yourself.
When to open an issue
If your problem doesn't match any of the categories above, a minimal reproducible example against packages/core/test/fixtures/ style fixtures is by far the fastest path to a fix. Include:
- Source document (or a minimal equivalent)
- Host setup (class / element / framework)
- Browser + version, and whether the same repro behaves differently in Vitest browser mode vs. a real browser tab
- Full console output from both the host and the source frame
- For cross-origin issues: the exchange of
vf:*messages (paste thepostMessagelog from both sides)