We have a desktop product that customers actively use, and we want to migrate toward a SaaS offering. In practice, that means we need backwards compatibility while we ship new features.
During that transition you often end up in a "hybrid" state: a desktop shell still exists, but more and more UI and logic moves into web technology (for example hosted inside WebView2).
That hybrid state introduces a core challenge: how do you preserve everyday interactions across boundaries? Drag & drop, keyboard copy/paste, focus, selection, and other "it just works" behaviors tend to break the moment parts of the UI live in different browsing contexts (iframes/windows) or even in a host process.
Demo repo (reference implementation): https://github.com/EelcoLos/iframe-dnd-demo
Live demo: https://eelcolos.github.io/iframe-dnd-demo/
A pragmatic way to do incremental delivery is to introduce new modules behind explicit boundaries:
- embed legacy/new pieces via IFrame
- split experiences into separate windows when the host is desktop (or when multi-monitor workflows help)
- use Web Components to build reusable UI pieces without betting on one framework
The next question becomes: how do those pieces communicate so interactions (drag & drop, keyboard copy/paste) still work across boundaries. And how does it bridge to the desktop host?
This post outlines a simple architecture: message passing all the way down, but with clear layers:
- DOM messaging for iframe ↔ parent (
window.postMessage) - Cross-window transport when needed (BroadcastChannel with fallbacks)
- WebView2 web messaging for web ↔ host (
window.chrome.webview.postMessage)
+------------------------ Desktop host (WebView2) -------------------------+
| WebMessageReceived <--- chrome.webview.postMessage(...) |
| ^ |
| | CoreWebView2.PostWebMessageAsJson/String(...) |
| | |
| +---------------- Parent web shell (coordinator) --------------------+ |
| | iframe <-> parent: window.postMessage | |
| | cross-window (optional): BroadcastChannel | |
| | fallback: postMessage relay via coordinator (Firefox ETP) | |
| +--------------------------------------------------------------------+ |
+--------------------------------------------------------------------------+
In the web layer, the parent shell acts as a coordinator:
- routes messages between iframes
- handles cross-iframe pointer interactions (coordinate conversion, hit-testing)
- holds shared state (e.g., clipboard-like state for keyboard copy/paste)
Then the parent shell optionally bridges certain messages to the desktop host.
Layer 1: iframe ↔ parent (DOM postMessage)
This is regular browser messaging:
- set an explicit
targetOrigin(avoid'*') - validate the sender origin on receive
In the demo, this routing enables:
- pointer-based drag & drop across iframes
- keyboard copy/paste across iframes
Layer 2: parent ↔ desktop host (WebView2 WebMessage)
WebView2 provides a separate channel:
- JS → host:
window.chrome.webview.postMessage(...) - host → JS:
CoreWebView2.PostWebMessageAsJson/String(...) - JS receive:
window.chrome.webview.addEventListener('message', ...)
Microsoft’s docs emphasize treating web content as untrusted and validating origins / message payloads.
Message contracts: be explicit (the “action/type” field)
The implementation choice I like most in this demo: every message carries a clear discriminator so the receiver can route behavior.
Web ↔ web (iframes/windows): type
In the browser-to-browser layer, the repo uses type values like dragStart, parentDrop, itemCopied, requestPaste, etc.
Example shapes from the repo’s API.md:
{ "type": "dragStart", "text": "Item 1", "id": "1", "source": "frame-a" }
{ "type": "parentDrop", "x": 123, "y": 456, "dragData": { "id": "1" } }
If you want versioning too, you can wrap that idea:
{
"v": 1,
"type": "itemCopied",
"source": "frame-a",
"payload": { "itemData": { "id": "1" } }
}
Web ↔ host (WebView2): action
For the WebView2 host bridge, the demo uses an action field for the same purpose (route on action in WebMessageReceived).
Example shape:
{ "action": "copy", "description": "Item", "quantity": "12", "unitPrice": "450" }
Contract in action (from the demo repo)
JS side (from public/webcomponent-table-source-html5.html) posts JSON strings to the WebView2 host:
// If running in WebView2 (C# host), also send copy to native code
if (window.chrome && window.chrome.webview) {
window.chrome.webview.postMessage(JSON.stringify({
action: 'copy',
description: selectedRow.dataset.desc,
quantity: selectedRow.dataset.qty,
unitPrice: selectedRow.dataset.price
}));
}
The same page also posts other actions like dragstart, dragend, and (on double-click) drop.
C# side (from WebView2App/HybridModeWindow.xaml.cs) receives and routes based on action:
WebViewSource.CoreWebView2.WebMessageReceived += CoreWebView2_WebMessageReceived;
private void CoreWebView2_WebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
{
string? message = null;
try
{
message = e.TryGetWebMessageAsString();
}
catch (ArgumentException)
{
// Message is not a string, try getting it as JSON
message = e.WebMessageAsJson;
}
// Message format examples:
// {"action":"drop","description":"...","quantity":12,"unitPrice":450}
// {"action":"copy","description":"...","quantity":"12","unitPrice":"450"}
if (string.IsNullOrEmpty(message)) return;
using var json = System.Text.Json.JsonDocument.Parse(message);
var root = json.RootElement;
if (!root.TryGetProperty("action", out var actionProp)) return;
var action = actionProp.GetString();
if (action == "drop")
{
// parse description/quantity/unitPrice and add to the target DataGridView
}
else if (action == "copy")
{
// store the copied data so Ctrl+V in the target window can paste it
}
}
Why it matters:
- you can be backwards compatible across modules
- you can implement request/response via
correlationId - you can validate shape (and reject unknown/untrusted messages)
Testing strategy
- In-web behavior: Playwright is a good fit, treating iframes and windows as first-class.
- Testing iframe drag and drop with Playwright
- Cross-window behavior: add tests that open child pages from a coordinator and assert keyboard interactions.
- Host bridge behavior: test separately at the desktop integration layer (WebMessageReceived handlers, navigation/origin checks).
Security checklist (practical)
- Validate origins for DOM postMessage: set a strict
targetOriginon send, and on receive validateevent.origin(allowlist) and optionallyevent.sourcebefore trustingevent.data. - In WebView2, always check the current document origin before trusting messages.
- Prefer JSON messages and validate schema.
- Disable features you don’t need (host objects, web messaging, scripts).
Sources
- Web/native interop: https://learn.microsoft.com/en-us/microsoft-edge/webview2/how-to/communicate-btwn-web-native
- WebView2 security: https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/security
- Frames in WebView2: https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/frames
- Demo repo: https://github.com/EelcoLos/iframe-dnd-demo
Top comments (0)