Skip to main content

Embedding on Windows: <Frame>, <foreign>, and being embedded

Three questions, one public API each, and they are already written. What this page works out is which Windows mechanism sits under each, and what that costs — not what to call any of it.

The rule the whole page is written against: the API does not change. An app writes <Frame src>, <foreign windowId> and <window embeddable> and asks useSupports('embedding'); which primitive answers is the backend's business and nobody else's.

Where this stands today​

<Frame src> is built and works. Steps 1 and 2 below are done; what follows them is still open ground. The numbers, because the whole point of the element is a number:

the host's event loop, while the pane computesbeats in 9smedianp95worst
the pane in its own process26111ms12ms13ms
the same module inline17211ms114ms411ms

Same component, same work, mounted two ways. Framed, the host never stalls longer than a frame; inline it stalls for a third of a second and loses a third of its heartbeats. That is the ticker not breaking stride.

<foreign> still refuses here, and still says so:

react-x11: <foreign> needs a backend with cross-process window embedding,
and this one has none — so nothing can be put in it. Ask
useSupports('embedding') before rendering one.

which is correct and deliberate — see the section on it below. <Frame> no longer goes near that path: a backend that composites panes from a shared buffer answers createPaneHost, and <Frame> dispatches on that instead.

Steps 3 and 4 are done too: no pane is forked before anyone asks whether it can be shown, and an embeddable window hands out a handle a host can embed. What is left is <foreign> — the one direction this page recommends not building — and OLE, which the finding below makes a larger question than it looked.

The two directions are different problems​

who owns the pixelswhat the other side must be
<Frame src>a pane of our own app, another processnothing — we start it
<foreign windowId>another application's windowa cooperating or ordinary app
<window embeddable>us, inside somebody else's UIa host that knows how to place us

X11 answers all three with one primitive — reparenting, with XEmbed as the handshake — which is why they look like one feature there. Windows has no single primitive that covers them, so each needs deciding on its own.

<Frame>: the surface path — how it was built​

The seam already exists and is tiny. A backend that composites panes from shared memory declares itself with app.createPaneHost(wnd), returning an object with three methods (src/cocoa/panehost.js is the whole reference implementation, 78 lines):

setRect(rect); // where the pane goes, in the host's window
present(surfaceId); // the pane published a new buffer; show it
destroy();

Everything else — the fork, the props channel, the bridged theme, the crash fallback, layout and hit testing — is backend-agnostic and already runs here.

On Cocoa surfaceId is an IOSurface. The Windows counterpart is a DirectComposition surface handle, and the machinery around it is mostly in place already:

  • WindowVisual(windowId) (windows/src/bridge.h) hands back a window's root DComp visual, and glcontext.cc already parents a child visual into it for <glarea>. A pane host is the same move with different content.
  • BridgeD3DDevice() is the shared D3D11 device.

What is missing is the cross-process buffer. Two candidates, and they are not equally good:

DCompositionCreateSurfaceHandle — purpose-built for this. The pane creates the handle, makes a composition swapchain against it (IDXGIFactory2::CreateSwapChainForCompositionSurfaceHandle), and draws; the host opens it with IDCompositionDevice::CreateSurfaceFromHandle and hands it to a visual with SetContent. It is an NT handle, so it crosses processes with DuplicateHandle like any other. This is the path Chromium uses for out-of-process content, and the flip is the composition engine's rather than ours — no explicit synchronisation to get wrong.

A shared DXGI texture — IDXGIResource1::CreateSharedHandle on the pane side, ID3D11Device1::OpenSharedResource1 on the host's. More general, and it puts synchronisation back on us: a keyed mutex or a shared fence per frame, which is a class of bug the first option does not have.

Recommendation: the DComp surface handle. It matches how the rest of this backend already presents, it needs no fence protocol across the process boundary, and present(handle) then means what present(iosurfaceId) means on Cocoa — "this buffer is the current one" — which keeps the seam honest rather than only similar.

The one real question a spike had to answer: the pane process must create DComp objects, which means a D3D device of its own. Two devices, one adapter; the handle is shared, the devices are not.

Answered: the pane keeps the bridge unchanged. start() already builds this process's devices, the pane calls it like any other process, and the handle crosses. What the spike also turned up, and what no document states, is two facts the implementation rests on — both now asserted by the bridge's test/pane.js, because both fail silently:

  • A pane's back buffer is two frames stale. A flip chain recycles buffers rather than keeping a bitmap, so a pane repainting only its damage leaves the rest of itself showing frame N-2. Five presents, the fifth touching one corner: the rest came back as the third. The pane window therefore repaints this frame's damage and the last frame's (STALE_FRAMES, src/win32/panewindow.js), as one clipped pass over their bounding box — one pass and not one per rect, because two passes over the same pixel composite it twice and a translucent node painted twice is the wrong colour.
  • A pane has to be clipped to its rect. The two sides disagree about a pane's size for as long as a round trip takes, and every shrink of a <Frame>'s box is one of those. Unclipped, the frames in between paint at the old size over whatever is beside the pane; paneSetRect now sets the visual's clip, so a pane that has not caught up is short of its corner instead.

<foreign>: child HWNDs, and why it is the least attractive of the three​

SetParent across processes works, and it is the closest thing Windows has to reparenting. It is also the one the platform warns about, and the warning is real: parenting across a process boundary attaches the two threads' input queues, so a hung child hangs the parent's input — docs/windows.md already says this of <foreign> and it is the reason to be slow here.

It is worth noting what the X11 version is used for (docs/embedding.md): a terminal pane, a video surface, a docked tray icon, a control-panel applet. On Windows the first two are better served by other things — a terminal is a pseudoconsole this app can render itself, and video is a swapchain — and the last two do not exist as embeddable objects at all.

Recommendation: leave <foreign> refusing on Windows. It already refuses honestly, useSupports('embedding') already says so, and a SetParent implementation would buy a narrow set of cases at the cost of handing any embedded program the ability to freeze the host. Revisit if a concrete need turns up; do not build it speculatively.

<window embeddable>: being the guest​

This is the one the API is most ready for and the one with the most choices under it. The prop already means exactly the right thing — "created, not shown; somebody else will place me" — and core already honours it on every backend by not mapping the window.

As a child HWND — measured, and it does not work. This was the recommendation on this page, and it is wrong. A composition target bound to one of our windows stops presenting the moment that window becomes a child, and the guest goes blank. Three ways of asking, one answer:

what was triedthe guest's own pixels
top-level, composed, drawn — the baseline30800 / 30800
…then SetParent into another window as WS_CHILD0 / 30800
created, reparented, and composed only afterwards0 / 30800
the same again without WS_EX_NOREDIRECTIONBITMAP0 / 30800

So it is not an ordering mistake and not the no-redirection style: a DirectComposition target wants a top-level window. Reading the host back cannot see this either way — a child's composition tree is its own and PrintWindow on the parent never contains it — which is why the numbers above are the guest's own pixels, with a screen capture agreeing.

What a child HWND would cost, then, is not SetParent: it is a second presentation path for embeddable windows, drawing through a redirection bitmap instead of a composition surface. That is not a flag. It is beginDraw per damage rect, scrollRegion, transparency and rounded popups — the whole surface model this backend is built on — written again, for the one case that cannot have the first one.

What to do instead: publish the buffer, not the window. The pane mechanism already built for <Frame> is the answer, pointed the other way: a guest makes a composition surface handle, draws into it, and hands the handle out; the host binds it to a visual in its own tree. It exists, it is tested, it needs no reparenting, and it does not attach the two processes' input queues — so the input-queue caveat that makes <foreign> unattractive here does not apply to the guest direction at all.

It asks something of the host: the host must be able to bind a composition surface, which WinUI 3, another react-x11 app, a DComp-based Electron host or a game editor can, and a plain Win32 or OLE document host cannot. That is the real trade, and it is a better one than a second renderer.

The open question is the API, not the mechanism. X11's guest hands out a window id from windowIdOf(ref); Windows' would hand out a buffer handle, which is the same shape and a different number — "same public API, different implementation", exactly as asked. What it needs is a name and a home, and that is a decision rather than a build.

As an OLE embedding, for actual documents. "Embed into a Windows document" in the literal sense — a pane inside a Word or Outlook document — is OLE Compound Documents: register a CLSID, implement IOleObject, IOleInPlaceObject, IViewObject2 and IDataObject, and in-place activation gives us a child HWND in the host's document, which is the previous paragraph with a large COM preamble in front of it.

It is a real answer to a real question, and it is a lot: a registered local server, apartment discipline, and a storage format for the "not currently activated" state (a document has to show something when the object is not running — a metafile or a bitmap, which window.snapshot() can already produce). Worth its own decision, not worth folding into this one.

Built: windowHandleOf(ref) / useWindowHandle(ref). <window embeddable> already meant the right thing on every backend; this is the number that goes with it, and it is deliberately not windowIdOf — on X11 the two are the same, and that is exactly why nobody noticed they were two questions.

backendwhat it hands out
X11the XID — the same number in any process
Windowsa composition surface handle, valid in the host
anything elsenull, which is the capability

An embeddable window on Windows is therefore not an HWND at all: it is the same shared buffer a <Frame> pane draws into, and createRoot({ win32: { paneHostPid } }) names the host when it is not the parent process. An app writes the same thing everywhere and passes the number out of band, as an XID is passed; what the host does with it is the host's business.

OLE, if it is ever wanted, is a separate question about a host that cannot bind a surface, not a variation on this one.

What must not change​

  • <Frame src> stays one element that works on every backend, with the same props, fallback and restart().
  • useSupports('embedding') stays the question an app asks before rendering <foreign>, and stays false here until something answers it.
  • <window embeddable> keeps meaning "created, not shown" everywhere.
  • Nothing grows a Windows-only name. A DComp surface handle is what present() carries here, the way an IOSurface id is what it carries on macOS — the seam names neither (AGENTS.md, "Vocabulary").

Order of work​

  1. Spike the two-device question — done. The pane creates its own D3D11 device through the ordinary start(), and the handle crosses. It also turned up the two silent facts above.
  2. Win32PaneHost, against the existing three-method seam — done, with examples/frame.jsx as the acceptance test and the table at the top of this page as its result. src/win32/panewindow.js is the pane's half, src/win32/panehost.js the host's, and windows/src/pane.cc the eight exports under them.
  3. Do not fork a pane that cannot be shown. — done. <Frame> now asks before the fork: createPaneHost or canEmbed, either of which is a way to show a pane, and neither of which needs a process to find out. Backend-agnostic, and test/frame-capability.test.js holds all three answers.
  4. Publish an embeddable window's buffer handle — done. windowHandleOf(ref) / useWindowHandle(ref), answering an XID on X11 and a surface handle here, with test/window-handle.test.js holding the difference. Not its HWND, which the table above rules out.
  5. OLE, if a document embedding is actually wanted — and now a bigger question than it looked, because in-place activation renders into a child HWND and this backend cannot draw into one.

Steps 1 and 2 are what made <Frame src> mean the same thing on three backends. Everything after them is new ground.