Skip to main content

The Wayland backend

Status: a working backend, opt-in. react-x11's own reconciler renders a window on a real Wayland compositor (mutter/GNOME, sway, labwc) with decorations — the compositor's where it draws them, client-side where it does not — text, shadows, paths, images, live partial repaints, pointer and keyboard input decoded against the compositor's keymap, a clipboard, popups, layer-shell docks and panels, and a screen capture with an eyedropper on top — drawn on the GPU and handed over as dma-buf, on Node or Bun, with no X server and no Xwayland anywhere in the path.

This is the companion to wayland.md, the research RFC it implements. Where the two disagree, this one is the one that ran.

REACT_X11_BACKEND=wayland node your-app.mjs # x11-dri >= 0.9 (native socket)
REACT_X11_BACKEND=wayland bun your-app.mjs # or Bun's bun:ffi transport
node examples/wayland-gl.mjs # GPU plasma, paced by the compositor
node examples/wayland-ui.mjs # every drawing primitive, no React
node --import tsx --test 'test/wayland/*.test.js' # the suite: no display needed

The two packages it needs beyond react-x11's own are optional dependencies, installed with it: @windowkit/wayland, the protocol layer — a fork of wayland-client that passes file descriptors — and x11-dri 0.9 or later, the GPU context and, on Node, the socket.

The backend is never chosen by 'auto': X11 stays the default on Linux because the remote case (remote.md) is the flagship reason this project exists and Wayland has no network transparency.

How to tell it is not Xwayland​

A GNOME session answers $DISPLAY too, through Xwayland, so "the window appeared" proves nothing. Three checks that do:

  • Unset DISPLAY. The Wayland backend never opens it; the X11 backend cannot start without it.
    env -u DISPLAY REACT_X11_BACKEND=wayland node app.mjs # works only natively
  • Ask the app. root.app.backend === 'wayland', and root.app.X is the stand-in from app.js — no display.screen, no X.require. The examples print the transport they connected through (connected via x11-dri or fdpass-bun) and never import an X module at all.
  • Ask the desktop. xlsclients and xwininfo -root -tree list Xwayland clients; a native window is absent from both. GNOME's Looking Glass (Alt+F2, lg, Windows tab) shows it as a MetaWindowWayland, and its frame is this backend's own titlebar (decorations.js) rather than mutter's Adwaita frame, which only Xwayland windows get.

What runs​

piecefilewhat it does
connectionconnection.jsfd-capable transport (native or bun:ffi), vendored protocol JSON
shellwindow.jsxdg toplevels and popups, deferred ack_configure, fractional scale
GPU presentationswapchain.js, dmabuf.js, glcontext.js, target.jsbacking target → per-buffer damage → dma-buf
2d contextcontext2d.js, glyphatlas.jsrects/rounded rects (SDF), paths (coverage masks, stencil), exact clips, gradients, images, text
<glarea>glarea.jsa rect of the window's backing target as the area's GL surface; composited panes for its children
inputseat.js, xkb.js, keysymnames.js, input.jspointer frames, keymap parsing, key repeat, routing
touch and tablettouch.js, tablet.jswl_touch and zwp_tablet_v2, emulating the pointer; raw touches
decorationsdecorations.js, ssd.jsxdg-decoration or KDE's, to hand the frame to the compositor; the client-side frame where neither
layer surfaceslayershell.jsdocks, panels, wallpapers, overlays — wlr-layer-shell
screen capturescreencopy.js, shm.jswlr-screencopy / ext-image-copy-capture into wl_shm; the eyedropper
offscreen surfacessurface.js, device.jsthe paint cache and scroll blits, over a render target; a held context works
clipboardclipboard.js, fdutil.jswl_data_device + primary selection, over pipes
screensoutputs.jswl_output + xdg_output into screens.js: useScreens(), the cap
input methodstextinput.jszwp_text_input_v3: the compositor's IME into the composition events
the appapp.js, backendwindow.js, framewatch.jswhat createRoot({ backend: 'wayland' }) renders through
teststest/wayland/an in-process compositor, and the pure parts

Running the examples​

Every example runs on this backend the way it runs on X11, with the display taken away so nothing can fall back:

env -u DISPLAY REACT_X11_BACKEND=wayland bun examples/widgets.jsx # or node --import tsx

Two are X11 by nature and say so: examples/wm.jsx is a window manager and examples/xeyes.jsx reads the root window's pointer — neither has a Wayland counterpart. examples/viewer3d.jsx runs but draws its model only through indirect GLX (it is fixed-function GL by design) and shows its own note here, as it does under glPolicy: 'direct' on X11.

Two environment variables help when a desktop offers no screenshot API: REACT_X11_WAYLAND_TRACE=1 writes a line per presented frame, and REACT_X11_WAYLAND_SNAPSHOT=/tmp/frame.png writes the backing target of the first window at its 30th present (REACT_X11_WAYLAND_SNAPSHOT_AT changes which) — the pixels the compositor was handed, read back from the GPU. A third, REACT_X11_WAYLAND_FRAME_STALL_MS, is how long a frame callback may go unanswered before the backend says the compositor has stopped showing the window (2000; 0 turns that off) — see A surface the compositor stops showing stops drawing.

The GPU context is made on glPolicy.devicePath, else on REACT_X11_GL_DEVICE, else on the first render node, and a device named either way is the only one tried. A machine with no render node at all — a GitHub-hosted runner, a Hyper-V or VirtualBox guest — usually still has a card node Mesa renders on in software through kms_swrast, so the backend then tries each /dev/dri/card* in turn. On a hosted runner that is card1, and headless weston with its GL renderer accepts the buffers it makes, as scripts/wayland-ci-probe.mjs measured.

What was measured​

Everything below was run on this machine (mutter, virtio-gpu/virgl over an Apple M1 Pro host, 2488×1668 @ 75Hz). Numbers from a VM's GPU are the VM's; the protocol numbers are not.

The protocol layer is not the cost​

Asked first, because it decides whether a native protocol bridge is justified. Measured with @windowkit/wayland, per request and per event:

measureBunNode (native socket)
encode + write, async API0.32 M req/s0.20 M req/s
encode + write, $ synchronous API0.49 M req/s0.67 M req/s (1.5 µs)
decode + dispatch2.3 M ev/s3.0 M ev/s (0.33 µs)
round trip sync → done, plain socket0.141 ms p500.119 ms p50
round trip, fd-capable socket0.178 ms p50 (reader thread)0.097 ms p50

A frame issues a handful of requests and input peaks near a thousand events a second: the JavaScript protocol layer is one to two orders of magnitude below anything visible. No native protocol bridge was built, on purpose. The one native piece is the socket itself, and only because Node cannot receive descriptors at all; its round trip turned out lower than Node's own net.Socket's, and the thread-hop cost of the Bun transport (~37 µs) is real but not worth acting on.

Where a frame goes​

phasep50
makeCurrent0.04 ms
draw0.05 ms
Wayland (attach / damage / commit)0.09 ms
Surface.swap()24.5 ms

The cost is swap(), and it is environmental: with no compositor at all, swap() is 0.07 ms and glFinish() is ~10 ms — the same at 64×64 as at 720×480. Size-independent means host round-trip latency, not fill rate; this is virgl in a VM. On bare metal the number should mostly disappear and must be re-measured there before any conclusion about the backend's performance is drawn from it.

The 2d context on the UI demo: 252 quads, 239 glyphs and 2 paths in 23 draw calls.

The design, where it is not the obvious one​

A backing target, not the swapchain, is what gets painted. react-x11's renderer repaints dirty rectangles into a persistent surface, as it would an X pixmap. A GBM swapchain rotates, so the buffer a frame lands in last showed the frame before last, and a partial repaint into it leaves stale pixels everywhere else. So the renderer paints into a persistent render target (target.js) and the changed rectangles are copied into whichever buffer comes round. GBM names the buffer only after the swap, so the copy uses the union of what every known buffer still owes — and everything, until a full rotation has passed without meeting a new buffer. The tests pin the rule (swapchain.test.js); a React counter ticking at 400 ms presents frames with one damage rectangle.

Analytic shapes first, stencil second. Rounded rectangles — most of a UI — are signed distance fields in the fragment shader: antialiased without multisampling, and every box in a window batches into one draw. Arbitrary paths (<svg>, ctx.fill(path2d)) are flattened by ntk's own flattenPath and filled stencil-then-cover on the target's stencil attachment. Strokes are the union of segment quads and join polygons under the non-zero rule. Box shadows are a feathered SDF (ctx.fillShadow, which boxpaint.js now prefers when a context has it) rather than a CPU blur through the paint cache.

An image in perspective is one quad. drawImageProjected(image, m) draws an image through a 3×3 projection — a plane turned out of the page, as CSS rotateY() in a perspective turns one — with each corner's texture coordinates over its w and the fragment dividing them back, which is exact across the quad. A context that can only draw through an affine matrix has to cut such a plane into tiles, each drawn through the matrix nearest the projection over it and clipped to it, and here every tile is a batch of its own because the clip is a scissor: <Html> drew Zen Garden 219's sidebar as 258 of them a frame, at 11 frames a second on a virgl VM, where seven tiles ran at 25. X11 has no such call on purpose: RENDER takes a projective picture transform, but glamor — Xwayland's, and most Xorg desktops' — divides at the corners and folds the image along its diagonal, and pixman runs out of 16.16 precision on a plane of any size. The method answers false and draws nothing where a corner is behind the viewer, for the caller to draw the part in front some other way.

The ack is deferred to the frame that adopts it. xdg_surface.configure is acknowledged immediately before the commit of a frame painted at the configured size, not on arrival — acking early tells the compositor the next commit is at the new size, and a buffer painted at the old one is then shown stretched.

Everything the renderer sees is content-relative device pixels. The surface is larger than the content by the client-side frame, and the compositor speaks logical pixels; the two conversions happen in backendwindow.js and input.js and nowhere else.

The keymap is parsed, not dlopen'd. The compositor sends an XKB keymap as a file descriptor; xkb.js reads keycodes, types (modifier → level maps), symbols per group, and resolves virtual modifiers like LevelThree to real ones through interpret + modifier_map — so AltGr works because the keymap says which bit it is, not because Mod5 was assumed. The output is both the X core keycode2keysyms table the accelerator code already speaks and a full decode() for levels 3 and 4. Key repeat is synthesised at the seat's advertised rate; compositors do not repeat.

The input method is told at the end of the frame. zwp_text_input_v3 wants to know which field has focus, what kind of text it holds, where its caret is and what surrounds it, and every one of those changes repaints the field — so the backend window calls textInput.sync() once per frame after the renderer has painted, when the layout is current, and only what changed since the last commit is sent. There is no hook into the event manager and nothing per field. What comes back — preedit_string, commit_string, delete_surrounding_text, applied together on done in the order the spec fixes — lands on the same CompositionStart/Update/End a dead key raises, so <textinput> and an application's onCompositionUpdate cannot tell IBus from compose.js. Keys the IME consumes never reach wl_keyboard.key, so key delivery is untouched; a key that arrives is one the IME declined, and the client-side composer has its usual go at it. A sensitive field offers no surrounding text and says sensitive_data; inputMode on a <textinput> becomes the content purpose (email, url, tel, …).

A finger and a pen are the pointer. On X11 a tablet is an XI2 slave of the master pointer and a touch emulates it, so every control in the tree is written against mousedown/mousemove/mouseup and nothing else. The Wayland seat keeps wl_touch and the tablet tools as devices of their own, so touch.js and tablet.js make them the pointer here: the first finger of a gesture and a tool in proximity emit the seat's own enter/motion/ buttonpress/buttonrelease/leave, carrying the touch down or tool down serial (what xdg_toplevel.move wants when the press lands on the titlebar), and input.js routes them through exactly the path the mouse takes — frame hit-testing, popup grabs, the cursor, which a tool in proximity follows through cursor-shape's tablet device. The emulated events alone carry pointerType ('touch', 'pen', 'eraser', 'mouse'), pressure, tiltX/tiltY, rotation, distance and tangentialPressure in the DOM's names and ranges, so a mouse event is unchanged and the X11 backend never sees a field it did not send. A second finger emulates nothing; every finger also arrives raw on the window as touchstart/touchmove/touchend/touchcancel — ntk's XI2 names, with its touchId — each with the active touches, batched at wl_touch.frame. Barrel buttons map as the desktop does: BTN_STYLUS is button 3, BTN_STYLUS2 button 2; proximity_out releases whatever is still held.

createWindow is synchronous. React's commit phase cannot await, and that is where windows are realised. Nothing about Wayland needs the asynchrony — a request is one-way and the client allocates ids — so the library grew a synchronous namespace (Wl_interface.$) and the only genuinely asynchronous step, binding globals, happens once in open().

The monitors are a fold over the registry. Each wl_output is its own global, so outputs.js binds every one (through a second wl_registry, because the library files globals by interface name and keeps one) and publishes the layout into screens.js the way the cocoa backend does: names from wl_output.name (DP-1, eDP-1), logical rects from xdg_output where the compositor has it and from the mode over the scale where it does not, converted to the renderer's device pixels at app.scale. Hot-plug is global/global_remove on that registry. Two things the protocol does not have are stood in for: a primary (the output at the origin), and a work area — xdg_toplevel.configure_bounds is the monitor less its panels, sent to a window ahead of its configure, so a window's bounds become the usable rect of the output it has entered, and every monitor carries its own rect so no head is clamped by another's. The densest output also seeds app.scale before the first window exists, which is the difference between a first layout at 2x and one corrected a frame later; and where a compositor offers neither fractional-scale-v1 nor preferred_buffer_scale, a surface's buffer scale is that of the output it entered.

A <glarea> is a rectangle of the backing target. On X11 it is a child window and on macOS a sublayer; a subsurface here would be a second swapchain, a second frame clock and a second dma-buf import per frame for what is already one GL context drawing into one target. So glarea.js hands the area the shared GLES table with viewport, scissor and bindFramebuffer(null) re-based on its rect (the Cocoa backend's own trick, prototype-delegated), and the frame loop orders the three layers: the tree's 2D is flushed under, the area draws over, and the panes its children were painted on — offscreen targets, createOverlayPane — are blended over that, translucent. One thing a shared target needs that a child window never did: when the tree repaints under a static scene, the scene is asked for its frame before the present, so the background never shows through for a frame.

One GL device, several contexts, and whoever draws takes it. A window's 2d context, the pane a <glarea>'s children are painted on and every offscreen Surface are the same GLES context, differing only in which framebuffer they bind and what they left the blend, scissor and stencil state saying — unlike an XRender context, which is an encoder aimed at a drawable, or a Cocoa one, which carries its state in its bitmap. So device.js gives the device an owner: a context claims it before it touches GL, and claiming is where the target is bound and the state the shader was written against is put back. That is what makes ntk's advice — hold a surface's context and draw into it whenever you like — true here too, where a held context used to draw wherever the device was last pointed and say nothing about it (#566); it is also why a node that painted into a surface in the middle of the frame does not have to restore the window's context afterwards. Foreign GL is the exception it cannot see: a <glarea> draws through entry points nobody tracks, so the frame loop still calls restoreGLState() after one.

Paths are antialiased on the CPU, and clips are exact on the stencil. A solid fill that fits the budget (512×512 after cutting to the clip) goes through ntk's analytic coverage rasteriser — the same pixels the X11 backend's local raster and every glyph get — into a per-frame mask atlas, drawn like a glyph; gradients and bigger shapes stay on stencil-then-cover. clip() with anything but one axis-aligned rectangle sets bit 7 of the stencil where the shape is (the low seven bits stay the winding count) and every batch under it tests that bit; nested clips intersect in place, restore() re-renders the restored list. The scissor still narrows to the bounds, so the test is only paid inside them. test/wayland/context2d-gpu.test.js reads the pixels back on any machine with a render node.

The glyph seam is public, and spelled the same way on every backend. drawGlyphs(op, src, positioned) is how TextLayout.draw reaches a context, and ntk documents it for callers that place their own glyphs too — a terminal grid, a tabular column. The source in that call is spelled ctx.createSolidPicture(r, g, b, a) and the op ctx.Render.PictOp.Over, so both of those answer here as they do on X11 and on Cocoa. Implementing drawGlyphs alone was not half a seam but a silent one: callers feature-detected, the test came back false, and the frame drew with every cell in its colour and no text in any of them rather than throwing (issue #565). What a solid is stays backend-private — an XRender Picture on X11, the premultiplied colour itself here — but a CSS colour string, a premultiplied [r, g, b, a] and null (meaning the fill style in force) are all accepted as sources as well, which is the set the Cocoa backend takes.

A surface the compositor stops showing stops drawing​

After the first present the frame loop has exactly one source of liveness: wl_surface.frame. A compositor is entitled never to send one to a surface it is not showing — covered, on another workspace, minimised, the screen locked — and that is the right way to drive a client, because painting into a surface nobody sees is work for nothing. There is no timer-driven fallback here and there should not be one.

What it costs is that the silence is not an error. Nothing rejects, the frame stays armed, and every later repaint request returns at its guard while React goes on re-rendering; from outside the process, an app that has drawn one frame and gone quiet is indistinguishable from a hang (#567 — 135 state ticks, one present, exit code 0). So the loop is watched (framewatch.js), and the parked state is reported twice over:

  • On stderr, once per window, in development, when the window still had something to draw. One line, naming the compositor as the reason and saying the app is not hung.
  • In the tree, as useWindowState().presenting, which also folds into visible — so an app can stop animating and simulating rather than feeding a loop nobody is turning, which is what every map, chart and game on this backend wants anyway.

Nothing is re-armed or polled to recover: the frame callback we asked for is still queued with the compositor and fires the moment the surface is shown again, which un-parks the loop and flips presenting back. The deadline is two seconds, and REACT_X11_WAYLAND_FRAME_STALL_MS changes it — 0 turns the watch off entirely, timer, warning and all.

Decorations: whose frame​

decorations.js draws a titlebar because mutter will not, and that stays the default where nothing better is offered. Where a compositor advertises zxdg_decoration_manager_v1 — wlroots (sway, labwc), KDE — each toplevel creates a decoration object before its first commit and asks for server-side decorations (ssd.js). The compositor's answer is a configure(mode) that is part of the surface's configure sequence, so it is adopted on the same ack as the size it arrived with: 'server' switches the client-side frame off (insets to zero, no titlebar painted, the content grows into the whole surface, which the tree sees as a resize), 'client' leaves it on. Before the first frame, where the compositor imposed no size, the surface is refitted to the content the tree asked for, so a floating window comes up the size it was declared and not a titlebar taller.

createRoot({ decorations }) says what to ask for: undefined/true/ 'server' prefer the compositor's frame, 'client' never asks for it, and false — as does <window decorations={false}> — wants no frame at all. A frameless window still creates the decoration object where the protocol exists, to decline server-side: sway frames every toplevel that has not said client_side.

Layer surfaces: docks, panels, wallpapers​

Where the compositor advertises zwlr_layer_shell_v1, a <window> whose windowType is one of these is not an xdg_toplevel but a layer surface (layershell.js), the same wl_surface, frame loop and input underneath:

windowTypelayeranchored toexclusive zone
'dock'topthe edge its shape and position imply (see below)its thickness
'desktop'backgroundall four edges-1
'notification'toptop and right, 12px in0
'splash'overlaynothing — the compositor centres it0

A dock's edge is read the way an X window manager reads the same window: wide and at y={0} is a top panel, wide otherwise a bottom one; tall and at x={0} a left dock, tall otherwise a right one. Its size is what the tree measured (or width/height), so a 560×56 dock sits centred on its edge with 56px reserved; anchoring it to both ends of the edge stretches it:

<window windowType="dock" height={56}>…</window>
<window windowType="dock" height={40} layerShell={{ anchor: ['bottom', 'left', 'right'] }}>…</window>
<window layerShell={{ layer: 'overlay', anchor: 'bottom', margin: { bottom: 40 } }}>an OSD</window>

layerShell names anything the type's default gets wrong — layer, anchor (an edge, an array of edges, 'all', 'none'), exclusiveZone (a number, 'auto' for the thickness, -1), margin, keyboardInteractivity ('none', 'on-demand', 'exclusive'), namespace, output — and layerShell={false} keeps a dock-typed window an ordinary toplevel. Popups from a layer surface work (a dock's menus): the xdg_popup is created parentless and adopted through zwlr_layer_surface_v1.get_popup. A stretched axis is the compositor's: resize() on it is not granted, and the configured size wins. Without layer-shell — GNOME — the same JSX is a toplevel, as before.

Screen capture, and the eyedropper​

app.screenCapture (screencopy.js) is there when the compositor offers either ext_image_copy_capture_manager_v1 (wlroots 0.19+, KDE 6.2+) or zwlr_screencopy_manager_v1 (sway 1.10 has this one), and null otherwise (GNOME). capture() answers one frame of the output as straight RGBA, the shape readback.js's encodePNG takes; pixelAt(x, y) one device pixel of it. The pixels land in a wl_shm pool (shm.js) whose memory is a memfd (or an unlinked /dev/shm file without x11-dri), read back with fs.readSync — there is no mmap in Node or Bun, and one copy of one screen on a path nobody runs per frame is the cheaper trade than a native binding.

pickColor() is the eyedropper, and it is what every wlroots colour picker does: freeze the output into a capture, show that capture as a layer-shell overlay anchored to every edge with an exclusive keyboard, take the click on the overlay — whose coordinates are the output's — read the pixel out of the capture, and take the overlay down. Escape cancels; Return, space and KP_Enter pick at the pointer. It answers { r, g, b } in 0–1, the portal's units, so src/screencolor.js converts it with the same function as the portal's and the cocoa sampler's. There it is rung 3 of the ladder: below the Screenshot portal (the desktop's own picker, user-consented — GNOME's only route, and what sway with xdg-desktop-portal-wlr uses too) and above the X11 grab. useEyedropper().supported is true on a bare sway; the rung is guarded by app.backend === 'wayland' because the capability is a Wayland object through and through.

Two protocol gaps are worth knowing. @windowkit/wayland keeps one global per interface name, so the output bound here is the one advertised last; capture({ output }) and layerShell.output take another proxy, but enumerating them waits on outputs.js. And the portal's parent_window handle names an X window; a Wayland toplevel has none without xdg-foreign, so the portal dialog floats.

Under a second compositor​

mutter was the only compositor this backend had met until sway 1.10.1 and labwc 0.8.3 ran here, nested (WLR_BACKENDS=wayland) and headless (WLR_BACKENDS=headless), which is also the first time a screenshot of the whole path was possible — grim needs wlr-screencopy, which mutter refuses:

WLR_BACKENDS=headless sway -c sway.conf & # output * resolution 1280x800
WAYLAND_DISPLAY=wayland-1 REACT_X11_BACKEND=wayland node --import tsx examples/simple.jsx &
WAYLAND_DISPLAY=wayland-1 grim shot.png

What it showed: the window comes up in sway's own frame (title bar and 2px border, default_border normal 2) with the client-side bar gone and the content filling the surface; a windowType="dock" window is a bottom layer surface with a reserved strip the tiled toplevel keeps clear of; the capture matches what grim sees; and the eyedropper's overlay maps and the pick resolves from a synthesised click. The one protocol bug it surfaced was in the client library rather than the compositor: it refused a null object argument (set_parent(null), set_fullscreen(null), get_popup(null, …), get_layer_surface(…, null, …)). @windowkit/wayland 3.1.1 takes null wherever the protocol marks an argument allow-null, and the backend passes it directly.

The transports, and Node​

Descriptors are the protocol: the keymap, shm pools and clipboard pipes all arrive as fds, and Node's sockets abort on them (nodejs/node#53391, closed).

  • x11-dri UnixSocket (>= 0.9): a uv_poll over an AF_UNIX socket, recvmsg/sendmsg with SCM_RIGHTS, no thread. Works on Node. Two things it had to get right that a first version did not: N-API values made from a libuv callback need a handle scope, and callbacks from the loop must go through napi_make_callback — napi_call_function from there runs the callback but never drains the promise-job queue, so a client awaiting its first reply hangs until an unrelated timer turns the loop. pipe(), socketpair() and memfdCreate() came with it.
  • node-x11's fdpass-bun.js: bun:ffi to libc, with a reader thread. Bun only. Bun exports libuv's symbol names but several are stubs that abort the process (uv_poll_init among them), so the native socket is never attempted under Bun; there is nothing to probe for safely. Its reader thread gave up on a healthy socket when poll(2) was interrupted sixty-four times in a row, and JavaScriptCore's thread-suspend signal (SIGPWR, tgkilled to every JS thread, the poller included) during WebAssembly compilation — yoga-layout's wasm tiering up ~0.3 s into any react-x11 app — produces exactly that burst, as strace -f -e ppoll,tgkill shows. EINTR is a retry since x11 4.2.1 (sidorares/node-x11#297).

What is left​

  • Multisampling. Stencil-then-cover (gradients, shapes past the CPU budget) and stencil clips are aliased at the edge. MSAA is the fix, and it is not the few lines it looks like: GLES 3.0 cannot blitFramebuffer into a multisampled framebuffer, so the scroll blits and the partial repaints the backing target exists for would have to be re-expressed as textured draws, and every read of the target (a present, getImageData, a drawImage of a surface) as a resolve first. virgl here reports MAX_SAMPLES of 1, so none of it could be validated; it waits for a machine that can run it.
  • <Frame> panes. The out-of-process pane path (createPaneHost) is macOS-only today — the pane presents an IOSurface id. Linux wants the same over a memfd or dma-buf fd on the pane channel; until then <Frame> here degrades the way it does without embedding.
  • Drag-and-drop. Wired (src/wayland/dnd.js): the drop side answers another application's drag over wl_data_device enter/motion/leave/drop, and the source side offers a payload and hands the gesture to start_drag, with an in-app drop keeping e.items by reference. What is left is the drag icon: start_drag is given a bare (invisible) surface, and rendering the tree's <popup dragPreview> into an icon surface the frame loop paints is a follow-up.
  • Input methods, the rest of it. The preedit's cursor is drawn (a heavier underline under the segment being converted) but nothing is done with a hidden cursor beyond putting the caret at the end, and the only content hints sent are multiline and sensitive_data — no spellcheck/auto_capitalization, which the tree has no notion of.
  • Tablet pads. The buttons, rings and strips on the tablet itself (zwp_tablet_pad_v2) are accepted and ignored: the tree has no vocabulary for them.
  • useScreens().source reads 'test', as it does on macOS: both backends publish through setScreensForTests, the one seam screens.js has, and it stamps the source. A source argument on it is the fix.
  • fillText only sees the first font family of a font shorthand and strokes text as a fill.
  • ext-image-copy-capture is implemented to the protocol text but has not run against a compositor: sway 1.10 advertises only wlr-screencopy, and GNOME neither. wlroots 0.19 (sway 1.11) is where to try it.

Things that are gone, not missing​

Window managers (examples/wm.jsx), global grabs, reading other windows, screen coordinates, GetImage, XEmbed/<foreign>, and ssh -X are compositor-private by design. Panels and docks are layer-shell, and a screen read is screencopy — both wlroots and KDE, neither GNOME, where the same JSX is a toplevel and the same pick is the portal's.

Changes outside src/wayland/​

  • src/Reconciler.js — a 'wayland' rung in resolveBackend(). Additive; 'auto' never selects it.
  • src/nodes/boxpaint.js — prefers ctx.fillShadow when a context has it.
  • src/nodes/preedit.js — the composition may carry a cursor (cursorBegin/cursorEnd on the composition event); without one the caret is at the end of the preedit, as before. src/events.js — _composition passes those extra fields through. Additive on both backends.
  • src/screencolor.js — rung 3 of the eyedropper's ladder, the Wayland backend's own picker, guarded by app.backend === 'wayland'; and the portal's parent_window handle is left empty on that backend, where an X window id would be a lie.
  • @windowkit/wayland 3.1.1 — a fork of wayland-client 3.0.0: fd send/receive, the $ synchronous request namespace, callback requests returning their done payload, and null for allow-null arguments.
  • x11-dri 0.9.0 — UnixSocket, pipe, socketpair, memfdCreate; a self-test that passes a real descriptor through a socketpair.
  • x11 4.2.1 — fdpass-bun.js retries a poll(2) that EINTR interrupted.