git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commitfacd847be2093d7ed48c63b0a6609860224f6411
parent3b8b9bd84a
authorIsaac Freund <[email protected]>
date2025-03-19 13:41
river: implement new rwm update sequence

 protocol/river-window-management-v1.xml |   9 +-
 river/Cursor.zig                        |   8 +-
 river/LockManager.zig                   |   2 +-
 river/Output.zig                        |  34 +--
 river/OutputManager.zig                 |  62 ++--
 river/PointerBinding.zig                |  35 ++-
 river/Seat.zig                          | 219 +++++++-------
 river/ShellSurface.zig                  |  36 +--
 river/Window.zig                        | 507 +++++++++++++++++---------------
 river/WindowManager.zig                 | 286 +++++++++---------
 river/WmNode.zig                        |  67 +++--
 river/XdgToplevel.zig                   |  90 +++---
 river/XkbBinding.zig                    |  48 +--
 river/XwaylandOverrideRedirect.zig      |   2 +-
 river/XwaylandWindow.zig                |  10 +-
 rivercompat/Window.zig                  |   3 +
 rivercompat/WindowManager.zig           |   9 +-
 17 files changed, 733 insertions(+), 694 deletions(-)

diff --git a/protocol/river-window-management-v1.xml b/protocol/river-window-management-v1.xml
index 4bcb773..427c0d2 100644
--- a/protocol/river-window-management-v1.xml
+++ b/protocol/river-window-management-v1.xml
@@ -91,7 +91,9 @@
     </description>
 
     <enum name="error">
-      <entry name="role" value="0"
+      <entry name="update_sequence_order" value="0"
+        sumary="request violates update sequence ordering"/>
+      <entry name="role" value="1"
         summary="given wl_surface already has a role"/>
     </enum>
 
@@ -988,8 +990,9 @@
         position. For example, this event is necessary to communicate the
         results of interactive resize to the window manager client.
 
-        This event will be followed by an update_windowing_start event after all
-        other new state has been sent by the server.
+        This event is sent during update sequences after the
+        update_windowing_finish request and before the update_rendering_start
+        event.
       </description>
       <arg name="x" type="int"/>
       <arg name="y" type="int"/>
diff --git a/river/Cursor.zig b/river/Cursor.zig
index 67d22a3..fec0416 100644
--- a/river/Cursor.zig
+++ b/river/Cursor.zig
@@ -483,12 +483,12 @@ pub fn processAxis(cursor: *Cursor, event: *const wlr.Pointer.event.Axis) void {
 fn interact(cursor: Cursor, result: Scene.AtResult) void {
     switch (result.data) {
         .window => |window| {
-            cursor.seat.pending.interaction = .{ .window = window };
-            server.wm.dirtyPending();
+            cursor.seat.windowing_scheduled.interaction = .{ .window = window };
+            server.wm.dirtyWindowing();
         },
         .shell_surface => |shell_surface| {
-            cursor.seat.pending.interaction = .{ .shell_surface = shell_surface };
-            server.wm.dirtyPending();
+            cursor.seat.windowing_scheduled.interaction = .{ .shell_surface = shell_surface };
+            server.wm.dirtyWindowing();
         },
         .lock_surface => |lock_surface| {
             assert(server.lock_manager.state != .unlocked);
diff --git a/river/LockManager.zig b/river/LockManager.zig
index b955681..184d836 100644
--- a/river/LockManager.zig
+++ b/river/LockManager.zig
@@ -203,7 +203,7 @@ fn handleUnlock(listener: *wl.Listener(void)) void {
 
     handleDestroy(&manager.destroy);
 
-    server.wm.dirtyPending();
+    server.wm.dirtyWindowing();
 }
 
 fn handleDestroy(listener: *wl.Listener(void)) void {
diff --git a/river/Output.zig b/river/Output.zig
index 1c46e98..4ab3d63 100644
--- a/river/Output.zig
+++ b/river/Output.zig
@@ -158,9 +158,9 @@ gamma_dirty: bool = false,
 /// Root.outputs
 link: wl.list.Link,
 
-/// Pending state to be sent to the wm in the next update sequence.
-pending: State = .{},
-/// State sent to the wm in the latest update sequence.
+/// State to be sent to the wm in the next windowing update sequence.
+scheduled: State = .{},
+/// State sent to the wm in the latest windowing update sequence.
 sent: State = .{},
 link_sent: wl.list.Link,
 /// State applied to the wlr_output and rendered.
@@ -207,12 +207,12 @@ pub fn create(wlr_output: *wlr.Output) !void {
     wlr_output.events.frame.add(&output.frame);
     wlr_output.events.present.add(&output.present);
 
-    output.pending.state = .enabled;
+    output.scheduled.state = .enabled;
     if (wlr_output.preferredMode()) |preferred_mode| {
-        output.pending.mode = .{ .standard = preferred_mode };
+        output.scheduled.mode = .{ .standard = preferred_mode };
     }
 
-    server.wm.dirtyPending();
+    server.wm.dirtyWindowing();
 }
 
 fn handleDestroy(listener: *wl.Listener(*wlr.Output), wlr_output: *wlr.Output) void {
@@ -231,13 +231,13 @@ fn handleDestroy(listener: *wl.Listener(*wlr.Output), wlr_output: *wlr.Output) v
 
     output.wlr_output = null;
     output.scene_output = null;
-    output.pending.state = .destroying;
+    output.scheduled.state = .destroying;
 
-    server.wm.dirtyPending();
+    server.wm.dirtyWindowing();
 }
 
-pub fn sendDirty(output: *Output) void {
-    switch (output.pending.state) {
+pub fn updateWindowingStart(output: *Output) void {
+    switch (output.scheduled.state) {
         .enabled, .disabled_soft => {
             if (server.wm.object) |wm_v1| {
                 const new = output.object == null;
@@ -255,7 +255,7 @@ pub fn sendDirty(output: *Output) void {
                 };
                 errdefer comptime unreachable;
 
-                const pending = &output.pending;
+                const pending = &output.scheduled;
                 const sent = &output.sent;
 
                 if (new or pending.width() != sent.width() or pending.height() != sent.height()) {
@@ -266,10 +266,10 @@ pub fn sendDirty(output: *Output) void {
                 }
             }
 
-            output.sent = output.pending;
+            output.sent = output.scheduled;
 
             output.link_sent.remove();
-            server.wm.sent.outputs.append(output);
+            server.wm.windowing_sent.outputs.append(output);
         },
         .disabled_hard, .destroying => {
             if (output.object) |output_v1| {
@@ -281,7 +281,7 @@ pub fn sendDirty(output: *Output) void {
             output.link_sent.remove();
             output.link_sent.init();
 
-            if (output.pending.state == .destroying) {
+            if (output.scheduled.state == .destroying) {
                 util.gpa.destroy(output);
             }
         },
@@ -330,16 +330,16 @@ fn handleRequestState(listener: *wl.Listener(*wlr.Output.event.RequestState), ev
     log.debug("backend requested new mode", .{});
 
     if (event.state.mode) |mode| {
-        output.pending.mode = .{ .standard = mode };
+        output.scheduled.mode = .{ .standard = mode };
     } else {
-        output.pending.mode = .{ .custom = .{
+        output.scheduled.mode = .{ .custom = .{
             .width = event.state.custom_mode.width,
             .height = event.state.custom_mode.height,
             .refresh = event.state.custom_mode.refresh,
         } };
     }
 
-    server.wm.dirtyPending();
+    server.wm.dirtyWindowing();
 }
 
 fn handleFrame(listener: *wl.Listener(*wlr.Output), wlr_output: *wlr.Output) void {
diff --git a/river/OutputManager.zig b/river/OutputManager.zig
index f982af4..10409d4 100644
--- a/river/OutputManager.zig
+++ b/river/OutputManager.zig
@@ -139,7 +139,7 @@ fn handleManagerApply(_: *wl.Listener(*wlr.OutputConfigurationV1), config: *wlr.
     while (it.next()) |head| {
         const output: *Output = @ptrFromInt(head.state.output.data);
 
-        output.pending = .{
+        output.scheduled = .{
             .state = if (head.state.enabled) .enabled else .disabled_hard,
             .mode = blk: {
                 if (head.state.mode) |mode| {
@@ -160,13 +160,13 @@ fn handleManagerApply(_: *wl.Listener(*wlr.OutputConfigurationV1), config: *wlr.
         };
     }
 
-    if (server.wm.pending.output_config) |old| {
+    if (server.wm.windowing_scheduled.output_config) |old| {
         old.sendFailed();
         old.destroy();
     }
-    server.wm.pending.output_config = config;
+    server.wm.windowing_scheduled.output_config = config;
 
-    server.wm.dirtyPending();
+    server.wm.dirtyWindowing();
 }
 
 fn validateConfigCoordinates(config: *wlr.OutputConfigurationV1) bool {
@@ -199,17 +199,17 @@ fn handlePowerManagerSetMode(
         event.output.name,
     });
 
-    switch (output.pending.state) {
+    switch (output.scheduled.state) {
         .enabled => {
-            if (event.mode == .off) output.pending.state = .disabled_soft else return;
+            if (event.mode == .off) output.scheduled.state = .disabled_soft else return;
         },
         .disabled_soft => {
-            if (event.mode == .on) output.pending.state = .enabled else return;
+            if (event.mode == .on) output.scheduled.state = .enabled else return;
         },
         .disabled_hard, .destroying => unreachable,
     }
 
-    server.wm.dirtyPending();
+    server.wm.dirtyWindowing();
 }
 
 fn handleSetGamma(
@@ -232,12 +232,12 @@ pub fn autoLayout(om: *OutputManager) void {
     {
         var it = om.outputs.iterator(.forward);
         while (it.next()) |output| {
-            if (output.pending.auto_layout) continue;
+            if (output.scheduled.auto_layout) continue;
 
-            const x = output.pending.x + output.pending.width();
+            const x = output.scheduled.x + output.scheduled.width();
             if (x > rightmost_edge) {
                 rightmost_edge = x;
-                row_y = output.pending.y;
+                row_y = output.scheduled.y;
             }
         }
     }
@@ -245,11 +245,11 @@ pub fn autoLayout(om: *OutputManager) void {
     {
         var it = om.outputs.iterator(.forward);
         while (it.next()) |output| {
-            if (!output.pending.auto_layout) continue;
+            if (!output.scheduled.auto_layout) continue;
 
-            output.pending.x = rightmost_edge;
-            output.pending.y = row_y;
-            rightmost_edge += output.pending.width();
+            output.scheduled.x = rightmost_edge;
+            output.scheduled.y = row_y;
+            rightmost_edge += output.scheduled.width();
         }
     }
 }
@@ -258,7 +258,7 @@ pub fn commitOutputState(om: *OutputManager) void {
     const wm = &server.wm;
 
     {
-        var it = wm.sent.outputs.iterator(.forward);
+        var it = wm.windowing_sent.outputs.iterator(.forward);
         while (it.next()) |output| {
             const wlr_output = output.wlr_output orelse continue;
             switch (output.sent.state) {
@@ -279,7 +279,7 @@ pub fn commitOutputState(om: *OutputManager) void {
     server.input_manager.reconfigureDevices();
 
     const need_modeset = blk: {
-        var it = wm.sent.outputs.iterator(.forward);
+        var it = wm.windowing_sent.outputs.iterator(.forward);
         while (it.next()) |output| {
             const wlr_output = output.wlr_output orelse continue;
 
@@ -313,7 +313,7 @@ pub fn commitOutputState(om: *OutputManager) void {
         defer for (states.items) |*s| s.base.finish();
 
         {
-            var it = wm.sent.outputs.iterator(.forward);
+            var it = wm.windowing_sent.outputs.iterator(.forward);
             while (it.next()) |output| {
                 const wlr_output = output.wlr_output orelse continue;
                 const state = states.addOne() catch {
@@ -336,20 +336,20 @@ pub fn commitOutputState(om: *OutputManager) void {
             log.err("failed to prepare new output configuration", .{});
             // TODO search for a working fallback
 
-            if (wm.sent.output_config) |config| {
+            if (wm.windowing_sent.output_config) |config| {
                 config.sendFailed();
                 config.destroy();
-                wm.sent.output_config = null;
+                wm.windowing_sent.output_config = null;
             }
 
             {
                 // Revert to last working state on failure
-                var it = wm.sent.outputs.iterator(.forward);
+                var it = wm.windowing_sent.outputs.iterator(.forward);
                 while (it.next()) |output| {
-                    output.pending = output.current;
+                    output.scheduled = output.current;
                     output.sent = output.current;
                 }
-                wm.dirtyPending();
+                wm.dirtyWindowing();
             }
             return;
         }
@@ -366,20 +366,20 @@ pub fn commitOutputState(om: *OutputManager) void {
         if (!server.backend.commit(states.items)) {
             log.err("failed to commit new output configuration", .{});
 
-            if (wm.sent.output_config) |config| {
+            if (wm.windowing_sent.output_config) |config| {
                 config.sendFailed();
                 config.destroy();
-                wm.sent.output_config = null;
+                wm.windowing_sent.output_config = null;
             }
 
             {
                 // Revert to last working state on failure
-                var it = wm.sent.outputs.iterator(.forward);
+                var it = wm.windowing_sent.outputs.iterator(.forward);
                 while (it.next()) |output| {
-                    output.pending = output.current;
+                    output.scheduled = output.current;
                     output.sent = output.current;
                 }
-                wm.dirtyPending();
+                wm.dirtyWindowing();
             }
             return;
         }
@@ -387,14 +387,14 @@ pub fn commitOutputState(om: *OutputManager) void {
         swapchain_manager.apply();
     }
 
-    if (wm.sent.output_config) |config| {
+    if (wm.windowing_sent.output_config) |config| {
         config.sendSucceeded();
         config.destroy();
-        wm.sent.output_config = null;
+        wm.windowing_sent.output_config = null;
     }
 
     {
-        var it = wm.sent.outputs.iterator(.forward);
+        var it = wm.windowing_sent.outputs.iterator(.forward);
         while (it.next()) |output| {
             output.current = output.sent;
 
diff --git a/river/PointerBinding.zig b/river/PointerBinding.zig
index b7ee0fe..c90bf88 100644
--- a/river/PointerBinding.zig
+++ b/river/PointerBinding.zig
@@ -31,25 +31,22 @@ const Seat = @import("Seat.zig");
 
 const log = std.log.scoped(.input);
 
-const WmState = struct {
-    enabled: bool = false,
-};
-
 seat: *Seat,
 object: *river.PointerBindingV1,
 
 button: u32,
 modifiers: river.SeatV1.Modifiers,
 
-pending: struct {
+windowing_scheduled: struct {
     state_change: enum {
         none,
         pressed,
         released,
     } = .none,
 } = .{},
-uncommitted: WmState = .{},
-committed: WmState = .{},
+windowing_requested: struct {
+    enabled: bool = false,
+} = .{},
 
 /// This bit of state is used to ensure that multiple simultaneous
 /// presses across multiple keyboards do not cause multiple press
@@ -111,8 +108,14 @@ fn handleRequest(
     assert(binding.object == pointer_binding_v1);
     switch (request) {
         .destroy => pointer_binding_v1.destroy(),
-        .enable => binding.uncommitted.enabled = true,
-        .disable => binding.uncommitted.enabled = false,
+        .enable => {
+            if (!server.wm.ensureWindowing()) return;
+            binding.windowing_requested.enabled = true;
+        },
+        .disable => {
+            if (!server.wm.ensureWindowing()) return;
+            binding.windowing_requested.enabled = false;
+        },
     }
 }
 
@@ -120,18 +123,18 @@ pub fn pressed(binding: *PointerBinding) void {
     assert(!binding.sent_pressed);
     // Input event processing should not continue after a press/release event
     // until that event is sent to the window manager in an update and acked.
-    assert(binding.pending.state_change == .none);
-    binding.pending.state_change = .pressed;
-    server.wm.dirtyPending();
+    assert(binding.windowing_scheduled.state_change == .none);
+    binding.windowing_scheduled.state_change = .pressed;
+    server.wm.dirtyWindowing();
 }
 
 pub fn released(binding: *PointerBinding) void {
     assert(binding.sent_pressed);
     // Input event processing should not continue after a press/release event
     // until that event is sent to the window manager in an update and acked.
-    assert(binding.pending.state_change == .none);
-    binding.pending.state_change = .released;
-    server.wm.dirtyPending();
+    assert(binding.windowing_scheduled.state_change == .none);
+    binding.windowing_scheduled.state_change = .released;
+    server.wm.dirtyWindowing();
 }
 
 pub fn match(
@@ -139,7 +142,7 @@ pub fn match(
     button: u32,
     modifiers: wlr.Keyboard.ModifierMask,
 ) bool {
-    if (!binding.committed.enabled) return false;
+    if (!binding.windowing_requested.enabled) return false;
 
     return button == binding.button and
         @as(u32, @bitCast(modifiers)) == @as(u32, @bitCast(binding.modifiers));
diff --git a/river/Seat.zig b/river/Seat.zig
index 9907f2f..a9541a3 100644
--- a/river/Seat.zig
+++ b/river/Seat.zig
@@ -73,18 +73,6 @@ pub const Event = union(enum) {
     pointer_pinch_end: wlr.Pointer.event.PinchEnd,
 };
 
-pub const WmState = struct {
-    focus: WmFocus = .none,
-    op: union(enum) {
-        none,
-        //TODO start_serial: ?u32,
-        start_pointer,
-        end,
-    } = .none,
-    // TODO confine region
-    // TODO pointer warp
-};
-
 pub const WmFocus = union(enum) {
     none,
     window: *Window,
@@ -123,25 +111,33 @@ object: ?*river.SeatV1 = null,
 
 event_queue: EventQueue = EventQueue.init(),
 
-/// State to be sent to the window manager client in the next update sequence.
-pending: struct {
+/// State to be sent to the wm in the next windowing update sequence.
+windowing_scheduled: struct {
     /// The window entered/hovered by the pointer, if any
     window: ?*Window = null,
     /// The window clicked on, touched, etc.
     interaction: WmFocus = .none,
 } = .{},
 
-/// State sent to the window manager client in the latest update sequence.
-sent: struct {
+/// State sent to the wm in the latest windowing update sequence.
+windowing_sent: struct {
     /// The window entered/hovered by the pointer, if any
     window: ?*Window = null,
 } = .{},
 link_sent: wl.list.Link,
 
-/// State requested by the window manager client but not yet committed.
-uncommitted: WmState = .{},
-/// State requested by the window manager client and committed.
-committed: WmState = .{},
+/// Windowing state requested by the wm.
+windowing_requested: struct {
+    focus: WmFocus = .none,
+    op: union(enum) {
+        none,
+        //TODO start_serial: ?u32,
+        start_pointer,
+        end,
+    } = .none,
+    // TODO confine region
+    // TODO pointer warp
+} = .{},
 
 xkb_bindings: wl.list.Head(XkbBinding, .link),
 pointer_bindings: wl.list.Head(PointerBinding, .link),
@@ -152,15 +148,17 @@ cursor: Cursor,
 op: ?struct {
     // We always want to process as many input events as possible before sending configures
     // and starting a transaction. Therefore, we set this flag if a seat operation modifies
-    // pending state and check it at the end of Seat.processEvents() rather than calling
-    // WindowManager.dirtyPending() directly in Seat.updateOp().
-    dirty: bool = false,
+    // Window.pending state and check it at the end of Seat.processEvents() rather than sending
+    // configures directly in Seat.updateOp().
+    need_configures: bool = false,
     input: enum {
         pointer,
     },
     /// Coordinates of the cursor/touch point/etc. at the start of the operation.
     start_x: i32,
     start_y: i32,
+    x: i32,
+    y: i32,
 } = null,
 
 relay: InputRelay,
@@ -203,7 +201,7 @@ pub fn create(name: [*:0]const u8) !void {
 
     server.input_manager.seats.append(seat);
     seat.link_sent.init();
-    server.wm.dirtyPending();
+    server.wm.dirtyWindowing();
 
     seat.xkb_bindings.init();
     seat.pointer_bindings.init();
@@ -228,10 +226,8 @@ pub fn destroy(seat: *Seat) void {
         var it = server.wm.windows.iterator(.forward);
         while (it.next()) |window| {
             inline for (.{
-                &window.uncommitted,
-                &window.committed,
-                &window.pending,
-                &window.sent,
+                &window.windowing_requested,
+                window,
             }) |state| {
                 switch (state.op) {
                     .none => {},
@@ -275,16 +271,12 @@ pub fn queueEvent(seat: *Seat, event: Event) void {
 pub fn processEvents(seat: *Seat) void {
     assert(server.wm.state == .idle);
 
-    // Only process events while there is no pending state to be sent to the window manager
-    // and no transaction in progress.
-    //
+    // Only process events while there is no new windowing state to be sent to the window manager.
     // The window manager might decide to change focus or redefine keyboard/pointer bindings
-    // in response to the pending update, which can affect further processing of events.
-    //
-    // Allowing event processing while there is a transaction in progress would require keeping
-    // track of additional state to differentiate pending state modified since the transaction
-    // was started. I don't see an advantage to that additional complexity.
-    while (server.wm.state == .idle and !server.wm.pending.dirty) {
+    // in response to the windowing update, which can affect further processing of events.
+    while (!server.wm.windowing_scheduled.dirty) {
+        assert(server.wm.state == .idle);
+
         const event = seat.event_queue.readItem() orelse break;
 
         const pg = server.input_manager.pointer_gestures;
@@ -307,16 +299,18 @@ pub fn processEvents(seat: *Seat) void {
             .pointer_pinch_end => |ev| pg.sendPinchEnd(seat.wlr_seat, ev.time_msec, ev.cancelled),
         }
     }
+    assert(server.wm.state == .idle);
 
     if (seat.op) |*op| {
-        if (op.dirty) {
-            op.dirty = false;
-            server.wm.sendConfigures();
+        if (op.need_configures) {
+            op.need_configures = false;
+            server.wm.state = .update_windowing;
+            server.wm.updateWindowingFinish();
         }
     }
 }
 
-pub fn sendDirty(seat: *Seat) void {
+pub fn updateWindowingStart(seat: *Seat) void {
     if (seat.destroying) {
         if (seat.object) |seat_v1| {
             seat_v1.sendRemoved();
@@ -340,52 +334,52 @@ pub fn sendDirty(seat: *Seat) void {
             wm_v1.sendSeat(seat_v1);
 
             seat.link_sent.remove();
-            server.wm.sent.seats.append(seat);
+            server.wm.windowing_sent.seats.append(seat);
 
             break :blk seat_v1;
         };
         errdefer comptime unreachable;
 
         if (new) {
-            if (seat.pending.window) |window| {
+            if (seat.windowing_scheduled.window) |window| {
                 if (window.object) |window_v1| {
                     seat_v1.sendPointerEnter(window_v1);
-                    seat.sent.window = seat.pending.window;
+                    seat.windowing_sent.window = seat.windowing_scheduled.window;
                 }
             }
-        } else if (seat.pending.window != seat.sent.window) {
-            if (seat.sent.window) |window| {
+        } else if (seat.windowing_scheduled.window != seat.windowing_sent.window) {
+            if (seat.windowing_sent.window) |window| {
                 if (window.object) |window_v1| {
                     seat_v1.sendPointerLeave(window_v1);
-                    seat.sent.window = null;
+                    seat.windowing_sent.window = null;
                 }
             }
-            if (seat.pending.window) |window| {
+            if (seat.windowing_scheduled.window) |window| {
                 if (window.object) |window_v1| {
                     seat_v1.sendPointerEnter(window_v1);
-                    seat.sent.window = window;
+                    seat.windowing_sent.window = window;
                 }
             }
         }
 
-        switch (seat.pending.interaction) {
+        switch (seat.windowing_scheduled.interaction) {
             .none => {},
             .window => |window| {
                 if (window.object) |window_v1| {
                     seat_v1.sendWindowInteraction(window_v1);
-                    seat.pending.interaction = .none;
+                    seat.windowing_scheduled.interaction = .none;
                 }
             },
             .shell_surface => |shell_surface| {
                 seat_v1.sendShellSurfaceInteraction(shell_surface.object);
-                seat.pending.interaction = .none;
+                seat.windowing_scheduled.interaction = .none;
             },
         }
 
         {
             var it = seat.xkb_bindings.iterator(.forward);
             while (it.next()) |binding| {
-                switch (binding.pending.state_change) {
+                switch (binding.windowing_scheduled.state_change) {
                     .none => {},
                     .pressed => {
                         assert(!binding.sent_pressed);
@@ -398,13 +392,13 @@ pub fn sendDirty(seat: *Seat) void {
                         binding.object.sendReleased();
                     },
                 }
-                binding.pending.state_change = .none;
+                binding.windowing_scheduled.state_change = .none;
             }
         }
         {
             var it = seat.pointer_bindings.iterator(.forward);
             while (it.next()) |binding| {
-                switch (binding.pending.state_change) {
+                switch (binding.windowing_scheduled.state_change) {
                     .none => {},
                     .pressed => {
                         assert(!binding.sent_pressed);
@@ -417,7 +411,7 @@ pub fn sendDirty(seat: *Seat) void {
                         binding.object.sendReleased();
                     },
                 }
-                binding.pending.state_change = .none;
+                binding.windowing_scheduled.state_change = .none;
             }
         }
     }
@@ -448,35 +442,48 @@ fn handleRequest(
         },
 
         .focus_window => |args| {
+            if (!server.wm.ensureWindowing()) return;
             const data = args.window.getUserData() orelse return;
             const window: *Window = @ptrCast(@alignCast(data));
-            seat.uncommitted.focus = .{ .window = window };
+            seat.windowing_requested.focus = .{ .window = window };
         },
         .focus_shell_surface => |args| {
+            if (!server.wm.ensureWindowing()) return;
             const data = args.shell_surface.getUserData() orelse return;
             const shell_surface: *ShellSurface = @ptrCast(@alignCast(data));
-            seat.uncommitted.focus = .{ .shell_surface = shell_surface };
+            seat.windowing_requested.focus = .{ .shell_surface = shell_surface };
         },
-        .clear_focus => seat.uncommitted.focus = .none,
+        .clear_focus => seat.windowing_requested.focus = .none,
 
-        .op_start_serial => {},
-        .op_start_pointer => seat.uncommitted.op = .start_pointer,
+        .op_start_serial => {
+            if (!server.wm.ensureWindowing()) return;
+            // XXX TODO
+        },
+        .op_start_pointer => {
+            if (!server.wm.ensureWindowing()) return;
+            seat.windowing_requested.op = .start_pointer;
+        },
         .op_add_move_window => |args| {
+            if (!server.wm.ensureWindowing()) return;
             const data = args.window.getUserData() orelse return;
             const window: *Window = @ptrCast(@alignCast(data));
-            window.uncommitted.op = .{ .move = .{
+            window.windowing_requested.op = .{ .move = .{
                 .seat = seat,
             } };
         },
         .op_add_resize_window => |args| {
+            if (!server.wm.ensureWindowing()) return;
             const data = args.window.getUserData() orelse return;
             const window: *Window = @ptrCast(@alignCast(data));
-            window.uncommitted.op = .{ .resize = .{
+            window.windowing_requested.op = .{ .resize = .{
                 .seat = seat,
                 .edges = args.edges,
             } };
         },
-        .op_end => seat.uncommitted.op = .end,
+        .op_end => {
+            if (!server.wm.ensureWindowing()) return;
+            seat.windowing_requested.op = .end;
+        },
 
         .pointer_confine_to_region => {},
         .pointer_warp => {},
@@ -512,34 +519,16 @@ fn handleRequest(
     }
 }
 
-pub fn commitWmState(seat: *Seat) void {
-    {
-        var it = seat.xkb_bindings.iterator(.forward);
-        while (it.next()) |binding| {
-            binding.committed = binding.uncommitted;
-        }
-    }
-    {
-        var it = seat.pointer_bindings.iterator(.forward);
-        while (it.next()) |binding| {
-            binding.committed = binding.uncommitted;
-        }
-    }
-
-    seat.committed = seat.uncommitted;
-    seat.uncommitted.op = .none;
-}
-
-pub fn applyCommitted(seat: *Seat) void {
+pub fn updateWindowingFinish(seat: *Seat) void {
     if (server.lock_manager.state != .unlocked) return;
 
-    switch (seat.committed.focus) {
+    switch (seat.windowing_requested.focus) {
         .none => seat.focus(.none),
         .window => |window| seat.focus(.{ .window = window }),
         .shell_surface => |shell_surface| seat.focus(.{ .shell_surface = shell_surface }),
     }
 
-    switch (seat.committed.op) {
+    switch (seat.windowing_requested.op) {
         .none => {},
         .start_pointer => if (seat.op == null) {
             log.debug("start seat op pointer", .{});
@@ -547,34 +536,36 @@ pub fn applyCommitted(seat: *Seat) void {
                 .input = .pointer,
                 .start_x = @intFromFloat(seat.cursor.wlr_cursor.x),
                 .start_y = @intFromFloat(seat.cursor.wlr_cursor.y),
+                .x = @intFromFloat(seat.cursor.wlr_cursor.x),
+                .y = @intFromFloat(seat.cursor.wlr_cursor.y),
             };
             seat.cursor.startOpPointer();
 
             {
                 var it = server.wm.windows.iterator(.forward);
                 while (it.next()) |window| {
-                    switch (window.committed.op) {
+                    switch (window.windowing_requested.op) {
                         .none => {},
                         .move => |data| {
                             if (data.seat == seat) {
-                                assert(window.pending.op == .none);
-                                window.pending.op = .{
+                                assert(window.op == .none);
+                                window.op = .{
                                     .move = .{
                                         .seat = seat,
-                                        .start_x = window.wm_pending.box.x,
-                                        .start_y = window.wm_pending.box.y,
+                                        .start_x = window.rendering_sent.box.x,
+                                        .start_y = window.rendering_sent.box.y,
                                     },
                                 };
                             }
                         },
                         .resize => |data| {
                             if (data.seat == seat) {
-                                assert(window.pending.op == .none);
-                                window.pending.op = .{
+                                assert(window.op == .none);
+                                window.op = .{
                                     .resize = .{
                                         .seat = seat,
                                         .edges = data.edges,
-                                        .start_box = window.wm_pending.box,
+                                        .start_box = window.rendering_sent.box,
                                     },
                                 };
                             }
@@ -593,11 +584,11 @@ pub fn applyCommitted(seat: *Seat) void {
             {
                 var it = server.wm.windows.iterator(.forward);
                 while (it.next()) |window| {
-                    switch (window.pending.op) {
+                    switch (window.op) {
                         .none => {},
                         inline .move, .resize => |data| {
                             if (data.seat == seat) {
-                                window.pending.op = .none;
+                                window.op = .none;
                             }
                         },
                     }
@@ -605,6 +596,7 @@ pub fn applyCommitted(seat: *Seat) void {
             }
         },
     }
+    seat.windowing_requested.op = .none;
 }
 
 pub fn focus(seat: *Seat, new_focus: Focus) void {
@@ -781,46 +773,45 @@ pub fn handleSwitchMapping(
 }
 
 pub fn updateOp(seat: *Seat, x: i32, y: i32) void {
-    const op = seat.op.?;
+    const op = &seat.op.?;
 
-    // Total dx/dy since operation start
-    const dx = x - op.start_x;
-    const dy = y - op.start_y;
+    op.x = x;
+    op.y = y;
 
+    // Position is not updated until the window has committed its new dimensions.
+    // The client may not commit exactly the dimensions we request and we need
+    // to know the actual committed dimensions to correctly place the top left
+    // corner in the case of a resize from the top or left edge.
     {
         var it = server.wm.windows.iterator(.forward);
         while (it.next()) |window| {
-            switch (window.pending.op) {
+            switch (window.op) {
                 .none => {},
                 .move => |data| {
                     if (data.seat != seat) continue;
 
-                    window.wm_pending.box.x = data.start_x + dx;
-                    window.wm_pending.box.y = data.start_y + dy;
-
-                    seat.op.?.dirty = true;
+                    op.need_configures = true;
                 },
                 .resize => |data| {
                     if (data.seat != seat) continue;
 
-                    // For resize, position is not updated until the window has committed
-                    // its new dimensions. The client may not commit exactly the dimensions
-                    // we request and we need to know the actual committed dimensions to
-                    // correctly place the top left corner in the case of a resize from
-                    // the top or left edge.
+                    // Total dx/dy since operation start
+                    const dx = x - op.start_x;
+                    const dy = y - op.start_y;
+
                     if (data.edges.left) {
-                        window.pending.width = @max(1, data.start_box.width - dx);
+                        window.configure_scheduled.width = @max(1, data.start_box.width - dx);
                     } else if (data.edges.right) {
-                        window.pending.width = @max(1, data.start_box.width + dx);
+                        window.configure_scheduled.width = @max(1, data.start_box.width + dx);
                     }
 
                     if (data.edges.top) {
-                        window.pending.height = @max(1, data.start_box.height - dy);
+                        window.configure_scheduled.height = @max(1, data.start_box.height - dy);
                     } else if (data.edges.bottom) {
-                        window.pending.height = @max(1, data.start_box.height + dy);
+                        window.configure_scheduled.height = @max(1, data.start_box.height + dy);
                     }
 
-                    seat.op.?.dirty = true;
+                    op.need_configures = true;
                 },
             }
         }
diff --git a/river/ShellSurface.zig b/river/ShellSurface.zig
index 11d33f2..c77f45f 100644
--- a/river/ShellSurface.zig
+++ b/river/ShellSurface.zig
@@ -32,12 +32,6 @@ const WmNode = @import("WmNode.zig");
 
 const log = std.log.scoped(.wm);
 
-const WmState = struct {
-    x: i32 = 0,
-    y: i32 = 0,
-    sync_next_commit: bool = false,
-};
-
 const role: wlr.Surface.Role = .{
     .name = "river_scene_surface_v1",
     .client_commit = clientCommit,
@@ -52,8 +46,11 @@ tree: *wlr.SceneTree,
 surfaces: Scene.SaveableSurfaces,
 node: WmNode,
 
-uncommitted: WmState = .{},
-committed: WmState = .{},
+rendering_requested: struct {
+    x: i32 = 0,
+    y: i32 = 0,
+    sync_next_commit: bool = false,
+} = .{},
 
 pub fn create(
     client: *wl.Client,
@@ -88,9 +85,8 @@ pub fn create(
         .surfaces = surfaces,
         .node = undefined,
     };
-
     shell_surface.node.init(.shell_surface);
-    server.wm.uncommitted.render_list.append(&shell_surface.node);
+    server.wm.rendering_requested.list.append(&shell_surface.node);
 
     shell_surface_v1.setHandler(*ShellSurface, handleRequest, handleDestroy, shell_surface);
 }
@@ -124,7 +120,8 @@ fn handleRequest(
             );
         },
         .sync_next_commit => {
-            shell_surface.uncommitted.sync_next_commit = true;
+            if (!server.wm.ensureRendering()) return;
+            shell_surface.rendering_requested.sync_next_commit = true;
         },
     }
 }
@@ -135,7 +132,7 @@ fn clientCommit(wlr_surface: *wlr.Surface) callconv(.C) void {
 
     const shell_surface: *ShellSurface = @ptrCast(@alignCast(resource.getUserData()));
 
-    if (shell_surface.uncommitted.sync_next_commit) {
+    if (shell_surface.rendering_requested.sync_next_commit) {
         shell_surface.surfaces.save();
     }
 }
@@ -148,22 +145,19 @@ fn commit(wlr_surface: *wlr.Surface) callconv(.C) void {
     }
 }
 
-pub fn commitWmState(shell_surface: *ShellSurface) void {
-    shell_surface.committed = shell_surface.uncommitted;
-
-    if (shell_surface.uncommitted.sync_next_commit) {
-        shell_surface.uncommitted.sync_next_commit = false;
+pub fn updateRenderingFinish(shell_surface: *ShellSurface) void {
+    const rendering_requested = &shell_surface.rendering_requested;
+    if (rendering_requested.sync_next_commit) {
+        rendering_requested.sync_next_commit = false;
 
         if (!shell_surface.surfaces.saved.node.enabled) {
             shell_surface.object.postError(.no_commit,
-                \\no wl_surface.commit after sync_next_commit and before river_window_manager_v1.commit
+                \\no wl_surface.commit after sync_next_commit and before update_rendering_finish
             );
         }
     }
-}
 
-pub fn commitTransaction(shell_surface: *ShellSurface) void {
     shell_surface.surfaces.dropSaved();
 
-    shell_surface.tree.node.setPosition(shell_surface.committed.x, shell_surface.committed.y);
+    shell_surface.tree.node.setPosition(rendering_requested.x, rendering_requested.y);
 }
diff --git a/river/Window.zig b/river/Window.zig
index 4839e47..fe34006 100644
--- a/river/Window.zig
+++ b/river/Window.zig
@@ -66,10 +66,9 @@ pub const Border = struct {
     a: u32 = 0,
 };
 
-pub const State = struct {
+pub const Configure = struct {
     width: ?u31 = null,
     height: ?u31 = null,
-    hidden: bool = false,
     /// True if the window has keyboard focus from at least one seat.
     activated: bool = false,
     ssd: bool = false,
@@ -78,59 +77,12 @@ pub const State = struct {
     capabilities: river.WindowV1.Capabilities = .{},
     maximized: bool = false,
     fullscreen: bool = false,
-
-    op: union(enum) {
-        none,
-        move: struct {
-            seat: *Seat,
-            start_x: i32,
-            start_y: i32,
-        },
-        resize: struct {
-            seat: *Seat,
-            edges: river.WindowV1.Edges = .{},
-            start_box: wlr.Box,
-        },
-    } = .none,
-};
-
-pub const WmState = struct {
-    position: ?struct {
-        x: i32,
-        y: i32,
-    } = null,
-    dimensions: ?struct {
-        width: u31,
-        height: u31,
-    } = null,
-    hidden: bool = false,
-    ssd: bool = false,
-    border: Border = .{},
-    tiled: river.WindowV1.Edges = .{},
-    capabilities: river.WindowV1.Capabilities = .{
-        .window_menu = true,
-        .maximize = true,
-        .fullscreen = true,
-        .minimize = true,
-    },
-    maximized: bool = false,
-    fullscreen: bool = false, // XXX output
-    close: bool = false,
-    op: union(enum) {
-        none,
-        move: struct {
-            seat: *Seat,
-        },
-        resize: struct {
-            seat: *Seat,
-            edges: river.WindowV1.Edges = .{},
-        },
-    } = .none,
+    resizing: bool = false,
 };
 
 /// The window management protocol object for this window
 /// Created after the window is ready to be configured.
-/// Lifetime is managed through wm_pending.state
+/// Lifetime is managed through windowing_scheduled.state
 object: ?*river.WindowV1 = null,
 node: WmNode,
 
@@ -159,8 +111,8 @@ destroying: bool = false,
 /// WindowManager.windows
 link: wl.list.Link,
 
-/// State to be sent to the window manager client in the next update sequence.
-wm_pending: struct {
+/// State to be sent to the wm in the next windowing update sequence.
+windowing_scheduled: struct {
     state: enum {
         /// Indicates that there is currently no associated river_window_v1
         /// object.
@@ -171,7 +123,6 @@ wm_pending: struct {
         /// Indicates that the closed event will be sent in the next update sequence.
         closing,
     } = .init,
-    box: wlr.Box = .{ .x = 0, .y = 0, .width = 0, .height = 0 },
     dimensions_hint: DimensionsHint = .{},
     decoration_hint: river.WindowV1.DecorationHint = .only_supports_csd,
     /// Set back to no_request at the end of each update sequence
@@ -185,25 +136,85 @@ wm_pending: struct {
     dirty_title: bool = false,
 } = .{},
 
-/// State sent to the window manager client in the latest update sequence.
+/// State sent to the wm in the latest windowing update sequence.
 /// This state is only kept around in order to avoid sending redundant events
-/// to the window manager client.
-wm_sent: struct {
-    position: ?struct { x: i32, y: i32 } = null,
-    dimensions: ?struct { width: i32, height: i32 } = null,
+/// to the wm.
+windowing_sent: struct {
     dimensions_hint: DimensionsHint = .{},
     decoration_hint: river.WindowV1.DecorationHint = .only_supports_csd,
 } = .{},
 
-/// State requested by the window manager client but not yet committed.
-uncommitted: WmState = .{},
-/// State requested by the window manager client and committed.
-committed: WmState = .{},
+/// Windowing state requested by the wm.
+windowing_requested: struct {
+    dimensions: ?struct {
+        width: u31,
+        height: u31,
+    } = null,
+    ssd: bool = false,
+    tiled: river.WindowV1.Edges = .{},
+    capabilities: river.WindowV1.Capabilities = .{
+        .window_menu = true,
+        .maximize = true,
+        .fullscreen = true,
+        .minimize = true,
+    },
+    maximized: bool = false,
+    fullscreen: bool = false, // XXX output
+    close: bool = false,
+    op: union(enum) {
+        none,
+        move: struct {
+            seat: *Seat,
+        },
+        resize: struct {
+            seat: *Seat,
+            edges: river.WindowV1.Edges = .{},
+        },
+    } = .none,
+} = .{},
 
 /// State to be sent to the window in the next configure.
-pending: State = .{},
+configure_scheduled: Configure = .{},
 /// State sent to the window in the latest configure.
-sent: State = .{},
+configure_sent: Configure = .{},
+
+/// State to be sent to the wm in the next rendering update sequence.
+rendering_scheduled: struct {
+    /// Dimensions committed by the window.
+    width: u31 = 0,
+    height: u31 = 0,
+    /// Send dimensions even if they are unchanged.
+    resend_dimensions: bool = false,
+} = .{},
+
+/// State sent to the wm in the latest rendering update sequence.
+rendering_sent: struct {
+    box: wlr.Box = .{ .x = 0, .y = 0, .width = 0, .height = 0 },
+} = .{},
+
+/// Rendering state requested by the wm.
+rendering_requested: struct {
+    position: ?struct {
+        x: i32,
+        y: i32,
+    } = null,
+    hidden: bool = false,
+    border: Border = .{},
+} = .{},
+
+op: union(enum) {
+    none,
+    move: struct {
+        seat: *Seat,
+        start_x: i32,
+        start_y: i32,
+    },
+    resize: struct {
+        seat: *Seat,
+        edges: river.WindowV1.Edges = .{},
+        start_box: wlr.Box,
+    },
+} = .none,
 
 pub fn create(impl: Impl) error{OutOfMemory}!*Window {
     assert(impl != .none);
@@ -254,14 +265,14 @@ pub fn destroy(window: *Window, when: enum { lazy, assert }) void {
 
     window.destroying = true;
 
-    // We can't assert(window.wm_pending.state != .ready) since the client may
+    // We can't assert(window.windowing_scheduled.state != .ready) since the client may
     // have exited after making its empty initial commit but before the surface
     // is mapped.
-    switch (window.wm_pending.state) {
+    switch (window.windowing_scheduled.state) {
         .init => {},
         .closing, .ready => {
-            window.wm_pending.state = .closing;
-            server.wm.dirtyPending();
+            window.windowing_scheduled.state = .closing;
+            server.wm.dirtyWindowing();
             return;
         },
     }
@@ -296,77 +307,54 @@ pub fn destroy(window: *Window, when: enum { lazy, assert }) void {
 }
 
 pub fn setDimensionsHint(window: *Window, hint: DimensionsHint) void {
-    window.wm_pending.dimensions_hint = hint;
-    if (!meta.eql(window.wm_sent.dimensions_hint, hint)) {
-        server.wm.dirtyPending();
+    window.windowing_scheduled.dimensions_hint = hint;
+    if (!meta.eql(window.windowing_sent.dimensions_hint, hint)) {
+        server.wm.dirtyWindowing();
     }
 }
 
-pub fn setDimensions(window: *Window, width: i32, height: i32) void {
-    window.wm_pending.box.width = width;
-    window.wm_pending.box.height = height;
-
-    switch (window.sent.op) {
-        .none, .move => {},
-        .resize => |data| {
-            assert(data.seat.op != null);
-
-            if (data.edges.left) {
-                window.wm_pending.box.x = data.start_box.x + data.start_box.width - width;
-            } else if (data.edges.right) {
-                window.wm_pending.box.x = data.start_box.x;
-            }
-
-            if (data.edges.top) {
-                window.wm_pending.box.y = data.start_box.y + data.start_box.height - height;
-            } else if (data.edges.bottom) {
-                window.wm_pending.box.y = data.start_box.y;
-            }
-        },
-    }
+pub fn setDimensions(window: *Window, width: u31, height: u31) void {
+    window.rendering_scheduled.width = width;
+    window.rendering_scheduled.height = height;
 
-    if (window.wm_sent.dimensions == null or window.wm_sent.position == null or
-        width != window.wm_sent.dimensions.?.width or
-        height != window.wm_sent.dimensions.?.height or
-        window.wm_pending.box.x != window.wm_sent.position.?.x or
-        window.wm_pending.box.y != window.wm_sent.position.?.y)
+    if (window.rendering_scheduled.resend_dimensions or
+        window.rendering_scheduled.width != window.rendering_sent.box.width or
+        window.rendering_scheduled.height != window.rendering_sent.box.height)
     {
-        server.wm.dirtyPending();
+        server.wm.dirtyRendering();
     }
 }
 
 pub fn setDecorationHint(window: *Window, hint: river.WindowV1.DecorationHint) void {
-    window.wm_pending.decoration_hint = hint;
-    if (hint != window.wm_sent.decoration_hint) {
-        server.wm.dirtyPending();
+    window.windowing_scheduled.decoration_hint = hint;
+    if (hint != window.windowing_sent.decoration_hint) {
+        server.wm.dirtyWindowing();
     }
 }
 
 pub fn setFullscreenRequested(window: *Window, fullscreen_requested: bool) void {
     if (fullscreen_requested) {
-        window.wm_pending.fullscreen_requested = .fullscreen;
+        window.windowing_scheduled.fullscreen_requested = .fullscreen;
     } else {
-        window.wm_pending.fullscreen_requested = .exit;
+        window.windowing_scheduled.fullscreen_requested = .exit;
     }
-    server.wm.dirtyPending();
+    server.wm.dirtyWindowing();
 }
 
-/// Send dirty pending state as part of an in progress update sequence.
-pub fn sendDirty(window: *Window) void {
-    switch (window.wm_pending.state) {
+/// Send dirty windowing state as part of a windowing update sequence.
+pub fn updateWindowingStart(window: *Window) void {
+    switch (window.windowing_scheduled.state) {
         .init => {},
         .closing => {
-            window.wm_pending.state = .init;
             window.initialized = false;
-            window.uncommitted = .{};
-            window.committed = .{};
+            window.windowing_scheduled.state = .init;
+            window.windowing_sent = .{};
+            window.windowing_requested = .{};
+            window.rendering_sent = .{};
+            window.rendering_requested = .{};
 
-            window.node.link_uncommitted.remove();
-            window.node.link_uncommitted.init();
-            window.node.link_committed.remove();
-            window.node.link_committed.init();
-            window.node.link_inflight.remove();
-            window.node.link_inflight.init();
+            window.node.link.remove();
+            window.node.link.init();
 
             window.makeInert();
         },
@@ -382,37 +370,16 @@ pub fn sendDirty(window: *Window) void {
                 window_v1.setHandler(*Window, handleRequest, handleDestroy, window);
                 wm_v1.sendWindow(window_v1);
 
-                server.wm.uncommitted.render_list.append(&window.node);
+                server.wm.rendering_requested.list.append(&window.node);
 
                 break :blk window_v1;
             };
             errdefer comptime unreachable;
 
-            const pending = &window.wm_pending;
-            const sent = &window.wm_sent;
+            const pending = &window.windowing_scheduled;
+            const sent = &window.windowing_sent;
 
             // XXX send all dirty pending state
-            if (new or sent.position == null or
-                pending.box.x != sent.position.?.x or pending.box.y != sent.position.?.y)
-            {
-                if (window.node.object) |node_v1| {
-                    node_v1.sendPosition(pending.box.x, pending.box.y);
-                    sent.position = .{
-                        .x = pending.box.x,
-                        .y = pending.box.y,
-                    };
-                }
-            }
-            if (!pending.box.empty() and (new or sent.dimensions == null or
-                pending.box.width != sent.dimensions.?.width or
-                pending.box.height != sent.dimensions.?.height))
-            {
-                window_v1.sendDimensions(window.wm_pending.box.width, window.wm_pending.box.height);
-                sent.dimensions = .{
-                    .width = pending.box.width,
-                    .height = pending.box.height,
-                };
-            }
             if (new or !meta.eql(pending.dimensions_hint, sent.dimensions_hint)) {
                 window_v1.sendDimensionsHint(
                     pending.dimensions_hint.min_width,
@@ -423,7 +390,7 @@ pub fn sendDirty(window: *Window) void {
                 sent.dimensions_hint = pending.dimensions_hint;
             }
             if (new or pending.decoration_hint != sent.decoration_hint) {
-                window_v1.sendDecorationHint(window.wm_pending.decoration_hint);
+                window_v1.sendDecorationHint(window.windowing_scheduled.decoration_hint);
                 sent.decoration_hint = pending.decoration_hint;
             }
             switch (pending.fullscreen_requested) {
@@ -475,13 +442,17 @@ fn handleRequest(
     window: *Window,
 ) void {
     assert(window.object == window_v1);
-    const uncommitted = &window.uncommitted;
+    const windowing_requested = &window.windowing_requested;
+    const rendering_requested = &window.rendering_requested;
     switch (request) {
         .destroy => {
             // XXX send protocol error
             window_v1.destroy();
         },
-        .close => uncommitted.close = true,
+        .close => {
+            if (!server.wm.ensureWindowing()) return;
+            windowing_requested.close = true;
+        },
         .get_node => |args| {
             if (window.node.object != null) {
                 window_v1.postError(.node_exists, "window already has a node object");
@@ -490,23 +461,37 @@ fn handleRequest(
             window.node.createObject(window_v1.getClient(), window_v1.getVersion(), args.id);
         },
         .propose_dimensions => |args| {
+            if (!server.wm.ensureWindowing()) return;
             if (args.width < 0 or args.height < 0) {
                 // XXX send protocol error
             }
-            uncommitted.dimensions = .{
+            windowing_requested.dimensions = .{
                 .width = @intCast(args.width),
                 .height = @intCast(args.height),
             };
         },
-        .hide => uncommitted.hidden = true,
-        .show => uncommitted.hidden = false,
-        .use_ssd => uncommitted.ssd = true,
-        .use_csd => uncommitted.ssd = false,
+        .hide => {
+            if (!server.wm.ensureRendering()) return;
+            rendering_requested.hidden = true;
+        },
+        .show => {
+            if (!server.wm.ensureRendering()) return;
+            rendering_requested.hidden = false;
+        },
+        .use_ssd => {
+            if (!server.wm.ensureWindowing()) return;
+            windowing_requested.ssd = true;
+        },
+        .use_csd => {
+            if (!server.wm.ensureWindowing()) return;
+            windowing_requested.ssd = false;
+        },
         .set_borders => |args| {
+            if (!server.wm.ensureRendering()) return;
             if (args.width < 0) {
                 // XXX send protocol error
             }
-            uncommitted.border = .{
+            rendering_requested.border = .{
                 .edges = args.edges,
                 .width = @intCast(args.width),
                 .r = args.r,
@@ -515,97 +500,83 @@ fn handleRequest(
                 .a = args.a,
             };
         },
-        .set_tiled => |args| uncommitted.tiled = args.edges,
+        .set_tiled => |args| {
+            if (!server.wm.ensureWindowing()) return;
+            windowing_requested.tiled = args.edges;
+        },
         .get_decoration_surface => {}, // XXX support decoration surfaces
-        .set_capabilities => |args| uncommitted.capabilities = args.caps,
-        .inform_maximized => uncommitted.maximized = true,
-        .inform_unmaximized => uncommitted.maximized = false,
-        .fullscreen => uncommitted.fullscreen = true,
-        .exit_fullscreen => uncommitted.fullscreen = false,
-    }
-}
-
-pub fn commitWmState(window: *Window) void {
-    if (!window.initialized and window.uncommitted.dimensions != null) {
-        window.initialized = true;
+        .set_capabilities => |args| {
+            if (!server.wm.ensureWindowing()) return;
+            windowing_requested.capabilities = args.caps;
+        },
+        .inform_maximized => {
+            if (!server.wm.ensureWindowing()) return;
+            windowing_requested.maximized = true;
+        },
+        .inform_unmaximized => {
+            if (!server.wm.ensureWindowing()) return;
+            windowing_requested.maximized = false;
+        },
+        .fullscreen => {
+            if (!server.wm.ensureWindowing()) return;
+            windowing_requested.fullscreen = true;
+        },
+        .exit_fullscreen => {
+            if (!server.wm.ensureWindowing()) return;
+            windowing_requested.fullscreen = false;
+        },
     }
-
-    window.committed = .{
-        .position = window.uncommitted.position orelse window.committed.position,
-        .dimensions = window.uncommitted.dimensions orelse window.committed.dimensions,
-        .hidden = window.uncommitted.hidden,
-        .ssd = window.uncommitted.ssd,
-        .border = window.uncommitted.border,
-        .tiled = window.uncommitted.tiled,
-        .capabilities = window.uncommitted.capabilities,
-        .maximized = window.uncommitted.maximized,
-        .fullscreen = window.uncommitted.fullscreen,
-        .close = window.uncommitted.close,
-        .op = window.uncommitted.op,
-    };
-    window.uncommitted.position = null;
-    window.uncommitted.dimensions = null;
-    window.uncommitted.op = .none;
 }
 
-/// Applies committed state from the window manager client to the inflight state.
+/// Applies windowing state from the window manager client  and sends a configure
+/// to the window if necessary.
 /// Returns true if the configure should be waited for by the transaction system.
-pub fn configure(window: *Window) bool {
-    if (!window.initialized) return false;
+pub fn updateWindowingFinish(window: *Window) bool {
+    const windowing_requested = &window.windowing_requested;
+
+    if (!window.initialized) {
+        if (windowing_requested.dimensions != null) {
+            window.initialized = true;
+        } else {
+            return false;
+        }
+    }
 
     assert(!window.destroying);
 
-    if (window.committed.close) {
+    window.configure_scheduled.ssd = windowing_requested.ssd;
+    window.configure_scheduled.tiled = windowing_requested.tiled;
+    window.configure_scheduled.capabilities = windowing_requested.capabilities;
+    window.configure_scheduled.maximized = windowing_requested.maximized;
+    window.configure_scheduled.fullscreen = windowing_requested.fullscreen;
+
+    if (windowing_requested.close) {
         window.close();
     }
 
-    const activated = blk: {
-        var it = server.wm.sent.seats.iterator(.forward);
+    {
+        window.configure_scheduled.activated = false;
+        var it = server.wm.windowing_sent.seats.iterator(.forward);
         while (it.next()) |seat| {
-            if (seat.committed.focus == .window and seat.committed.focus.window == window) {
-                break :blk true;
+            if (seat.windowing_requested.focus == .window and
+                seat.windowing_requested.focus.window == window)
+            {
+                window.configure_scheduled.activated = true;
+                break;
             }
         }
-        break :blk false;
-    };
-
-    const committed = &window.committed;
-
-    window.pending = .{
-        .width = window.pending.width,
-        .height = window.pending.height,
-        .hidden = committed.hidden,
-        .activated = activated,
-        .ssd = committed.ssd,
-        .border = committed.border,
-        .tiled = committed.tiled,
-        .capabilities = committed.capabilities,
-        .maximized = committed.maximized,
-        .fullscreen = committed.fullscreen,
-        .op = window.pending.op,
-    };
+    }
 
-    // Ensure a position/dimension event is sent if the window manager has
-    // modified them even if the actual position/dimensions do not change.
-    if (committed.position) |position| {
-        if (window.pending.op == .none) {
-            window.wm_pending.box.x = position.x;
-            window.wm_pending.box.y = position.y;
+    if (windowing_requested.dimensions) |dimensions| {
+        if (window.op == .none) {
+            window.configure_scheduled.width = dimensions.width;
+            window.configure_scheduled.height = dimensions.height;
         }
-
-        window.wm_sent.position = null;
-        committed.position = null;
+        windowing_requested.dimensions = null;
+        window.rendering_scheduled.resend_dimensions = true;
     }
-    if (committed.dimensions) |dimensions| {
-        if (window.pending.op == .none) {
-            window.pending.width = dimensions.width;
-            window.pending.height = dimensions.height;
-        }
 
-        window.wm_sent.dimensions = null;
-        committed.dimensions = null;
-    }
-    committed.op = .none;
+    windowing_requested.op = .none;
 
     const track_configure = switch (window.impl) {
         .toplevel => |*toplevel| toplevel.configure(),
@@ -621,7 +592,7 @@ pub fn configure(window: *Window) bool {
     return track_configure;
 }
 
-pub fn commitTransaction(window: *Window) void {
+pub fn updateRenderingStart(window: *Window) void {
     switch (window.impl) {
         .toplevel => |*toplevel| {
             switch (toplevel.configure_state) {
@@ -656,25 +627,85 @@ pub fn commitTransaction(window: *Window) void {
                 },
                 .timed_out, .timed_out_acked => unreachable,
             }
-            window.setDimensions(toplevel.geometry.width, toplevel.geometry.height);
+            window.rendering_scheduled.width = @intCast(toplevel.geometry.width);
+            window.rendering_scheduled.height = @intCast(toplevel.geometry.height);
         },
         .xwayland => |xwindow| {
-            window.setDimensions(xwindow.xsurface.width, xwindow.xsurface.height);
+            window.rendering_scheduled.width = xwindow.xsurface.width;
+            window.rendering_scheduled.height = xwindow.xsurface.height;
         },
         .none => {},
     }
 
-    window.updateSceneState();
+    const sent = &window.rendering_sent;
+    var scheduled_box: wlr.Box = .{
+        .x = sent.box.x,
+        .y = sent.box.y,
+        .width = window.rendering_scheduled.width,
+        .height = window.rendering_scheduled.height,
+    };
+
+    switch (window.op) {
+        .none => {},
+        .move => |data| {
+            const seat_op = &data.seat.op.?;
+            const dx = seat_op.x - seat_op.start_x;
+            const dy = seat_op.y - seat_op.start_y;
+            scheduled_box.x = data.start_x + dx;
+            scheduled_box.y = data.start_y + dy;
+            log.debug("set x/y for move to {} {}", .{ scheduled_box.x, scheduled_box.y });
+        },
+        .resize => |data| {
+            assert(data.seat.op != null);
+            if (data.edges.left) {
+                scheduled_box.x = data.start_box.x + data.start_box.width - scheduled_box.width;
+            } else if (data.edges.right) {
+                scheduled_box.x = data.start_box.x;
+            }
+            if (data.edges.top) {
+                scheduled_box.y = data.start_box.y + data.start_box.height - scheduled_box.height;
+            } else if (data.edges.bottom) {
+                scheduled_box.y = data.start_box.y;
+            }
+        },
+    }
+
+    if (scheduled_box.x != sent.box.x or scheduled_box.y != sent.box.y) {
+        if (window.node.object) |node_v1| {
+            node_v1.sendPosition(scheduled_box.x, scheduled_box.y);
+        }
+    }
+    if (window.rendering_scheduled.resend_dimensions or
+        scheduled_box.width != sent.box.width or scheduled_box.height != sent.box.height)
+    {
+        if (window.object) |window_v1| {
+            window_v1.sendDimensions(scheduled_box.width, scheduled_box.height);
+            window.rendering_scheduled.resend_dimensions = false;
+        }
+    }
+    sent.box = scheduled_box;
 }
 
-pub fn updateSceneState(window: *Window) void {
-    const box = &window.wm_pending.box;
+pub fn updateRenderingFinish(window: *Window) void {
+    window.tree.node.setEnabled(!window.rendering_requested.hidden);
+    window.popup_tree.node.setEnabled(!window.rendering_requested.hidden);
+
+    const box = &window.rendering_sent.box;
+
+    if (window.rendering_requested.position) |position| {
+        if (window.op == .none) {
+            box.x = position.x;
+            box.y = position.y;
+        }
+        window.rendering_requested.position = null;
+    }
+
     window.tree.node.setPosition(box.x, box.y);
     window.popup_tree.node.setPosition(box.x, box.y);
 
     // f32 cannot represent all u32 values exactly, therefore we must initially use f64
     // (which can) and then cast to f32, potentially losing precision.
-    const border = &window.sent.border;
+    const border = &window.rendering_requested.border;
     const color: [4]f32 = .{
         @floatCast(@as(f64, @floatFromInt(border.r)) / math.maxInt(u32)),
         @floatCast(@as(f64, @floatFromInt(border.g)) / math.maxInt(u32)),
@@ -805,17 +836,17 @@ pub fn unmap(window: *Window) void {
     assert(window.mapped and !window.destroying);
     window.mapped = false;
 
-    assert(window.wm_pending.state != .closing);
-    window.wm_pending.state = .closing;
-    server.wm.dirtyPending();
+    assert(window.windowing_scheduled.state != .closing);
+    window.windowing_scheduled.state = .closing;
+    server.wm.dirtyWindowing();
 }
 
 pub fn notifyTitle(window: *Window) void {
-    window.wm_pending.dirty_title = true;
-    server.wm.dirtyPending();
+    window.windowing_scheduled.dirty_title = true;
+    server.wm.dirtyWindowing();
 }
 
 pub fn notifyAppId(window: *Window) void {
-    window.wm_pending.dirty_app_id = true;
-    server.wm.dirtyPending();
+    window.windowing_scheduled.dirty_app_id = true;
+    server.wm.dirtyWindowing();
 }
diff --git a/river/WindowManager.zig b/river/WindowManager.zig
index a6f83ac..3f4c064 100644
--- a/river/WindowManager.zig
+++ b/river/WindowManager.zig
@@ -42,52 +42,41 @@ object: ?*river.WindowManagerV1 = null,
 
 state: union(enum) {
     idle,
-    /// An update event was sent to the window manager but has not yet been acked.
-    /// Value is the update serial
-    update_sent: u32,
-    /// An update event was sent to the window manager and has been acked but not yet committed.
-    update_acked,
+    /// Waiting on the window manager client to finish the windowing phase of the update sequence.
+    update_windowing,
     /// The number of configures sent that have not yet been acked
     inflight_configures: u32,
+    /// Waiting on the window manager client to finish the rendering phase of the update sequence.
+    update_rendering,
 } = .idle,
 
 windows: wl.list.Head(Window, .link),
 
-/// Pending state to be sent to the wm in the next update sequence.
-pending: struct {
-    /// Pending state has been modified since the last update event sent to the wm.
+/// Windowing state to be sent to the wm in the next windowing update sequence.
+windowing_scheduled: struct {
+    /// Windowing state has been modified since the last windowing update sequence.
     dirty: bool = false,
 
     output_config: ?*wlr.OutputConfigurationV1 = null,
 } = .{},
 
 /// State sent to the wm in the latest update sequence.
-sent: struct {
+windowing_sent: struct {
     outputs: wl.list.Head(Output, .link_sent),
     output_config: ?*wlr.OutputConfigurationV1 = null,
 
     seats: wl.list.Head(Seat, .link_sent),
 },
 
-/// State sent by the wm but not yet committed with a commit request.
-uncommitted: struct {
-    /// The list is in rendering order, the last node in the list is rendered on top.
-    render_list: wl.list.Head(WmNode, .link_uncommitted),
-},
-
-/// State sent by the wm and committed with a commit request.
-committed: struct {
-    // The wm has committed state since state was last sent to windows.
+/// Rendering state to be sent to the wm in the next rendering update sequence.
+rendering_scheduled: struct {
+    /// Rendering state has been modified since the last rendering update sequence.
     dirty: bool = false,
-    /// The list is in rendering order, the last node in the list is rendered on top.
-    render_list: wl.list.Head(WmNode, .link_committed),
-},
+} = .{},
 
-/// State committed by the wm that has been sent to windows as part of the
-/// current transaction.
-inflight: struct {
-    /// The list is in rendering order, the last node in the list is rendered on top.
-    render_list: wl.list.Head(WmNode, .link_inflight),
+/// The list is in rendering order, the last node in the list is rendered on top.
+rendering_requested: struct {
+    list: wl.list.Head(WmNode, .link),
 },
 
 dirty_idle: ?*wl.EventSource = null,
@@ -102,27 +91,19 @@ pub fn init(wm: *WindowManager) !void {
     wm.* = .{
         .global = try wl.Global.create(server.wl_server, river.WindowManagerV1, 1, *WindowManager, wm, bind),
         .windows = undefined,
-        .sent = .{
+        .windowing_sent = .{
             .outputs = undefined,
             .seats = undefined,
         },
-        .uncommitted = .{
-            .render_list = undefined,
-        },
-        .committed = .{
-            .render_list = undefined,
-        },
-        .inflight = .{
-            .render_list = undefined,
+        .rendering_requested = .{
+            .list = undefined,
         },
         .timeout = timeout,
     };
     wm.windows.init();
-    wm.sent.outputs.init();
-    wm.sent.seats.init();
-    wm.uncommitted.render_list.init();
-    wm.committed.render_list.init();
-    wm.inflight.render_list.init();
+    wm.windowing_sent.outputs.init();
+    wm.windowing_sent.seats.init();
+    wm.rendering_requested.list.init();
 
     server.wl_server.addDestroyListener(&wm.server_destroy);
 }
@@ -149,7 +130,7 @@ fn bind(client: *wl.Client, wm: *WindowManager, version: u32, id: u32) void {
 
     wm.object = object;
     object.setHandler(*WindowManager, handleRequest, handleDestroy, wm);
-    wm.dirtyPending();
+    wm.dirtyWindowing();
 }
 
 fn handleRequestInert(
@@ -180,43 +161,25 @@ fn handleRequest(
             // XXX send protocol error
             wm_v1.destroy();
         },
-        .ack_update => |args| {
-            switch (wm.state) {
-                .update_sent => |serial| {
-                    if (args.serial == serial) {
-                        wm.state = .update_acked;
-                    }
-                },
-                .idle, .update_acked, .inflight_configures => {},
+        .update_windowing_finish => {
+            if (wm.state != .update_windowing) {
+                wm_v1.postError(.update_sequence_order,
+                    \\update_windowing_finish request does not match update_windowing_start
+                );
+                return;
             }
+            wm.updateWindowingFinish();
         },
-        .commit => {
-            {
-                var it = wm.uncommitted.render_list.iterator(.forward);
-                while (it.next()) |node| {
-                    node.link_committed.remove();
-                    wm.committed.render_list.append(node);
-                    switch (node.get()) {
-                        .window => |window| window.commitWmState(),
-                        .shell_surface => |shell_surface| shell_surface.commitWmState(),
-                    }
-                }
-            }
-
-            {
-                var it = wm.sent.seats.iterator(.forward);
-                while (it.next()) |seat| seat.commitWmState();
-            }
-
-            wm.committed.dirty = true;
-            switch (wm.state) {
-                .idle, .update_acked => {
-                    wm.cancelTimeoutTimer();
-                    wm.sendConfigures();
-                },
-                .update_sent, .inflight_configures => {},
+        .update_rendering_finish => {
+            if (wm.state != .update_rendering) {
+                wm_v1.postError(.update_sequence_order,
+                    \\update_rendering_finish request does not match update_rendering_start
+                );
+                return;
             }
+            wm.updateRenderingFinish();
         },
+        .update_mark_dirty => wm.dirtyWindowing(),
         .get_shell_surface => |args| {
             const surface = wlr.Surface.fromWlSurface(args.surface);
             ShellSurface.create(
@@ -233,93 +196,125 @@ fn handleRequest(
     }
 }
 
-pub fn dirtyPending(wm: *WindowManager) void {
-    wm.pending.dirty = true;
+pub fn ensureWindowing(wm: *WindowManager) bool {
+    switch (wm.state) {
+        .update_windowing => return true,
+        .idle, .inflight_configures, .update_rendering => {
+            if (wm.object) |wm_v1| {
+                wm_v1.postError(.update_sequence_order, "invalid modification of windowing state");
+            }
+            return false;
+        },
+    }
+}
+
+pub fn ensureRendering(wm: *WindowManager) bool {
+    switch (wm.state) {
+        .update_windowing, .inflight_configures, .update_rendering => return true,
+        .idle => {
+            if (wm.object) |wm_v1| {
+                wm_v1.postError(.update_sequence_order, "invalid modification of rendering state");
+            }
+            return false;
+        },
+    }
+}
+
+pub fn dirtyWindowing(wm: *WindowManager) void {
+    wm.windowing_scheduled.dirty = true;
 
     if (wm.dirty_idle == null) {
         const event_loop = server.wl_server.getEventLoop();
-        wm.dirty_idle = event_loop.addIdle(*WindowManager, handleDirtyPending, wm) catch {
+        wm.dirty_idle = event_loop.addIdle(*WindowManager, dirtyIdle, wm) catch {
             log.err("out of memory", .{});
             return;
         };
     }
 }
 
-fn handleDirtyPending(wm: *WindowManager) void {
-    assert(wm.pending.dirty);
+pub fn dirtyRendering(wm: *WindowManager) void {
+    wm.rendering_scheduled.dirty = true;
+
+    if (wm.dirty_idle == null) {
+        const event_loop = server.wl_server.getEventLoop();
+        wm.dirty_idle = event_loop.addIdle(*WindowManager, dirtyIdle, wm) catch {
+            log.err("out of memory", .{});
+            return;
+        };
+    }
+}
+
+fn dirtyIdle(wm: *WindowManager) void {
+    assert(wm.windowing_scheduled.dirty or wm.rendering_scheduled.dirty);
     wm.dirty_idle = null;
     switch (wm.state) {
         .idle => {
-            assert(!wm.committed.dirty);
-            wm.sendUpdate();
+            if (wm.rendering_scheduled.dirty) {
+                wm.updateRenderingStart();
+            } else {
+                wm.updateWindowingStart();
+            }
         },
-        .update_sent, .update_acked, .inflight_configures => {},
+        .update_windowing, .inflight_configures, .update_rendering => {},
     }
 }
 
-fn sendUpdate(wm: *WindowManager) void {
+fn updateWindowingStart(wm: *WindowManager) void {
     assert(wm.state == .idle);
-    assert(wm.pending.dirty);
+    assert(wm.windowing_scheduled.dirty);
 
-    log.debug("sending update to window manager", .{});
+    log.debug("update windowing start", .{});
 
     server.om.autoLayout();
     {
         var it = server.om.outputs.safeIterator(.forward);
-        while (it.next()) |output| output.sendDirty();
+        while (it.next()) |output| output.updateWindowingStart();
     }
 
-    assert(wm.sent.output_config == null);
-    wm.sent.output_config = wm.pending.output_config;
-    wm.pending.output_config = null;
+    assert(wm.windowing_sent.output_config == null);
+    wm.windowing_sent.output_config = wm.windowing_scheduled.output_config;
+    wm.windowing_scheduled.output_config = null;
 
     {
         var it = wm.windows.safeIterator(.forward);
-        while (it.next()) |window| window.sendDirty();
+        while (it.next()) |window| window.updateWindowingStart();
     }
 
     {
         var it = server.input_manager.seats.safeIterator(.forward);
-        while (it.next()) |seat| seat.sendDirty();
+        while (it.next()) |seat| seat.updateWindowingStart();
     }
 
-    wm.pending.dirty = false;
+    wm.windowing_scheduled.dirty = false;
+    wm.state = .update_windowing;
 
     if (wm.object) |wm_v1| {
-        const serial = server.wl_server.nextSerial();
-        wm_v1.sendUpdate(serial);
-        wm.state = .{ .update_sent = serial };
-
-        wm.startTimeoutTimer();
+        // TODO kill the WM on a very long timeout?
+        wm_v1.sendUpdateWindowingStart();
     } else {
-        wm.sendConfigures();
+        wm.updateWindowingFinish();
     }
 }
 
-pub fn sendConfigures(wm: *WindowManager) void {
-    switch (wm.state) {
-        .idle, .update_acked => {},
-        .update_sent, .inflight_configures => unreachable,
-    }
+pub fn updateWindowingFinish(wm: *WindowManager) void {
+    assert(wm.state == .update_windowing);
 
-    wm.committed.dirty = false;
+    log.debug("update windowing finish", .{});
 
     {
-        // Order is important here, Seat.applyCommitted() must be called
-        // before configures are sent.
-        var it = wm.sent.seats.iterator(.forward);
-        while (it.next()) |seat| seat.applyCommitted();
+        // Order is important here, Seat.updateWindowingFinish() must be called
+        // before Window.updateWindowingFinish() are sent.
+        var it = wm.windowing_sent.seats.iterator(.forward);
+        while (it.next()) |seat| seat.updateWindowingFinish();
     }
 
     wm.state = .{ .inflight_configures = 0 };
     {
-        var it = wm.committed.render_list.iterator(.forward);
+        var it = wm.rendering_requested.list.iterator(.forward);
         while (it.next()) |node| {
-            node.link_inflight.remove();
-            wm.inflight.render_list.append(node);
             switch (node.get()) {
                 .window => |window| {
-                    if (window.configure()) {
+                    if (window.updateWindowingFinish()) {
                         wm.state.inflight_configures += 1;
                     }
                 },
@@ -328,12 +323,12 @@ pub fn sendConfigures(wm: *WindowManager) void {
         }
     }
 
-    log.debug("started transaction with {} configure(s)", .{wm.state.inflight_configures});
+    log.debug("sent {} tracked configure(s)", .{wm.state.inflight_configures});
 
     if (wm.state.inflight_configures > 0) {
         wm.startTimeoutTimer();
     } else {
-        wm.commitTransaction();
+        wm.updateRenderingStart();
     }
 }
 
@@ -351,10 +346,10 @@ fn cancelTimeoutTimer(wm: *WindowManager) void {
 fn handleTimeout(wm: *WindowManager) c_int {
     log.err("timeout occurred, some imperfect frames may be shown", .{});
 
-    assert(wm.state != .idle);
+    assert(wm.state.inflight_configures > 0);
+    wm.state.inflight_configures = 0;
 
-    wm.state = .{ .inflight_configures = 0 };
-    wm.commitTransaction();
+    wm.updateRenderingStart();
 
     return 0;
 }
@@ -363,19 +358,43 @@ pub fn notifyConfigured(wm: *WindowManager) void {
     wm.state.inflight_configures -= 1;
     if (wm.state.inflight_configures == 0) {
         wm.cancelTimeoutTimer();
-        wm.commitTransaction();
+        wm.updateRenderingStart();
+    }
+}
+
+fn updateRenderingStart(wm: *WindowManager) void {
+    assert(wm.state == .idle or wm.state.inflight_configures == 0);
+
+    log.debug("update rendering start", .{});
+
+    {
+        var it = wm.rendering_requested.list.iterator(.forward);
+        while (it.next()) |node| {
+            switch (node.get()) {
+                .window => |window| window.updateRenderingStart(),
+                .shell_surface => {},
+            }
+        }
+    }
+
+    wm.state = .update_rendering;
+    wm.rendering_scheduled.dirty = false;
+
+    if (wm.object) |wm_v1| {
+        // TODO kill the WM on a very long timeout?
+        wm_v1.sendUpdateRenderingStart();
+    } else {
+        wm.updateRenderingFinish();
     }
 }
 
-/// Apply the inflight state and drop stashed buffers. This means that
-/// the next frame drawn will be the post-transaction state of the
-/// layout. Should only be called after all clients have configured for
-/// the new layout. If called early imperfect frames may be drawn.
-fn commitTransaction(wm: *WindowManager) void {
-    assert(wm.state.inflight_configures == 0);
+/// Finish the update sequence and drop stashed buffers. This means that
+/// the next frame drawn will be the post-transaction state.
+fn updateRenderingFinish(wm: *WindowManager) void {
+    assert(wm.state == .update_rendering);
     wm.state = .idle;
 
-    log.debug("commiting transaction", .{});
+    log.debug("update rendering finish", .{});
 
     {
         var it = wm.windows.safeIterator(.forward);
@@ -386,20 +405,17 @@ fn commitTransaction(wm: *WindowManager) void {
     }
 
     {
-        var it = wm.inflight.render_list.iterator(.forward);
+        var it = wm.rendering_requested.list.iterator(.forward);
         while (it.next()) |node| {
             switch (node.get()) {
                 .window => |window| {
-                    window.commitTransaction();
+                    window.updateRenderingFinish();
 
                     window.tree.node.reparent(server.scene.layers.wm);
                     window.tree.node.raiseToTop();
-
-                    window.tree.node.setEnabled(!window.pending.hidden);
-                    window.popup_tree.node.setEnabled(!window.pending.hidden);
                 },
                 .shell_surface => |shell_surface| {
-                    shell_surface.commitTransaction();
+                    shell_surface.updateRenderingFinish();
 
                     shell_surface.tree.node.reparent(server.scene.layers.wm);
                     shell_surface.tree.node.raiseToTop();
@@ -419,10 +435,10 @@ fn commitTransaction(wm: *WindowManager) void {
 
     log.debug("finished committing transaction", .{});
 
-    if (wm.committed.dirty) {
-        wm.sendConfigures();
-    } else if (wm.pending.dirty) {
-        wm.dirtyPending();
+    if (wm.rendering_scheduled.dirty) {
+        wm.dirtyRendering();
+    } else if (wm.windowing_scheduled.dirty) {
+        wm.dirtyWindowing();
     } else {
         server.input_manager.processEvents();
     }
diff --git a/river/WmNode.zig b/river/WmNode.zig
index 7c8de0e..1120f6a 100644
--- a/river/WmNode.zig
+++ b/river/WmNode.zig
@@ -36,31 +36,21 @@ const Tag = @typeInfo(Type).Union.tag_type.?;
 tag: Tag,
 object: ?*river.NodeV1 = null,
 
-/// WindowManager.uncommitted.render_list
-link_uncommitted: wl.list.Link,
-/// WindowManager.committed.render_list
-link_committed: wl.list.Link,
-/// WindowManager.inflight.render_list
-link_inflight: wl.list.Link,
+/// WindowManager.rendering_requested.list
+link: wl.list.Link,
 
 pub fn init(node: *WmNode, tag: Tag) void {
     node.* = .{
         .tag = tag,
-        .link_uncommitted = undefined,
-        .link_committed = undefined,
-        .link_inflight = undefined,
+        .link = undefined,
     };
-    node.link_uncommitted.init();
-    node.link_committed.init();
-    node.link_inflight.init();
+    node.link.init();
 }
 
 pub fn deinit(node: *WmNode) void {
     assert(node.object == null);
 
-    node.link_uncommitted.remove();
-    node.link_committed.remove();
-    node.link_inflight.remove();
+    node.link.remove();
 }
 
 pub fn get(node: *WmNode) Type {
@@ -110,43 +100,52 @@ fn handleRequest(
         .destroy => {
             node_v1.destroy();
         },
-        .set_position => |args| switch (node.get()) {
-            .window => |window| {
-                window.uncommitted.position = .{
-                    .x = args.x,
-                    .y = args.y,
-                };
-            },
-            .shell_surface => |shell_surface| {
-                shell_surface.uncommitted.x = args.x;
-                shell_surface.uncommitted.y = args.y;
-            },
+        .set_position => |args| {
+            if (!server.wm.ensureRendering()) return;
+            switch (node.get()) {
+                .window => |window| {
+                    window.rendering_requested.position = .{
+                        .x = args.x,
+                        .y = args.y,
+                    };
+                },
+                .shell_surface => |shell_surface| {
+                    shell_surface.rendering_requested.x = args.x;
+                    shell_surface.rendering_requested.y = args.y;
+                },
+            }
         },
         .place_top => {
-            node.link_uncommitted.remove();
-            server.wm.uncommitted.render_list.append(node);
+            if (!server.wm.ensureRendering()) return;
+            node.link.remove();
+            server.wm.rendering_requested.list.append(node);
         },
         .place_bottom => {
-            node.link_uncommitted.remove();
-            server.wm.uncommitted.render_list.prepend(node);
+            if (!server.wm.ensureRendering()) return;
+            node.link.remove();
+            server.wm.rendering_requested.list.prepend(node);
         },
         .place_above => |args| {
+            if (!server.wm.ensureRendering()) return;
+
             const other_data = args.other.getUserData() orelse return;
             const other: *WmNode = @ptrCast(@alignCast(other_data));
 
             if (other == node) return;
 
-            node.link_uncommitted.remove();
-            other.link_uncommitted.insert(&node.link_uncommitted);
+            node.link.remove();
+            other.link.insert(&node.link);
         },
         .place_below => |args| {
+            if (!server.wm.ensureRendering()) return;
+
             const other_data = args.other.getUserData() orelse return;
             const other: *WmNode = @ptrCast(@alignCast(other_data));
 
             if (other == node) return;
 
-            node.link_uncommitted.remove();
-            other.link_uncommitted.prev.?.insert(&node.link_uncommitted);
+            node.link.remove();
+            other.link.prev.?.insert(&node.link);
         },
     }
 }
diff --git a/river/XdgToplevel.zig b/river/XdgToplevel.zig
index ab34c3c..411b530 100644
--- a/river/XdgToplevel.zig
+++ b/river/XdgToplevel.zig
@@ -127,7 +127,7 @@ pub fn configure(toplevel: *XdgToplevel) bool {
         .timed_out, .timed_out_acked, .committed => unreachable,
     };
 
-    const pending = &toplevel.window.pending;
+    const scheduled = &toplevel.window.configure_scheduled;
 
     if (!toplevel.needsConfigure()) {
         // If no new configure is required, continue to track a timed out configure
@@ -148,43 +148,43 @@ pub fn configure(toplevel: *XdgToplevel) bool {
 
     const wlr_toplevel = toplevel.wlr_toplevel;
 
-    _ = wlr_toplevel.setActivated(pending.activated);
+    _ = wlr_toplevel.setActivated(scheduled.activated);
     _ = wlr_toplevel.setTiled(.{
-        .top = pending.tiled.top,
-        .bottom = pending.tiled.bottom,
-        .left = pending.tiled.left,
-        .right = pending.tiled.right,
+        .top = scheduled.tiled.top,
+        .bottom = scheduled.tiled.bottom,
+        .left = scheduled.tiled.left,
+        .right = scheduled.tiled.right,
     });
     _ = wlr_toplevel.setWmCapabilities(.{
-        .window_menu = pending.capabilities.window_menu,
-        .maximize = pending.capabilities.maximize,
-        .fullscreen = pending.capabilities.fullscreen,
-        .minimize = pending.capabilities.minimize,
+        .window_menu = scheduled.capabilities.window_menu,
+        .maximize = scheduled.capabilities.maximize,
+        .fullscreen = scheduled.capabilities.fullscreen,
+        .minimize = scheduled.capabilities.minimize,
     });
-    _ = wlr_toplevel.setMaximized(pending.maximized);
-    _ = wlr_toplevel.setFullscreen(pending.fullscreen);
-    _ = wlr_toplevel.setResizing(pending.op == .resize);
+    _ = wlr_toplevel.setMaximized(scheduled.maximized);
+    _ = wlr_toplevel.setFullscreen(scheduled.fullscreen);
+    _ = wlr_toplevel.setResizing(scheduled.resizing);
     if (toplevel.decoration) |decoration| {
-        _ = decoration.wlr_decoration.setMode(if (pending.ssd) .server_side else .client_side);
+        _ = decoration.wlr_decoration.setMode(if (scheduled.ssd) .server_side else .client_side);
     }
 
-    const width: u31 = pending.width orelse switch (toplevel.configure_state) {
+    const width: u31 = scheduled.width orelse switch (toplevel.configure_state) {
         .idle => @intCast(toplevel.geometry.width),
-        .timed_out, .timed_out_acked => toplevel.window.sent.width.?,
+        .timed_out, .timed_out_acked => toplevel.window.configure_sent.width.?,
         .inflight, .acked, .committed => unreachable,
     };
-    const height: u31 = pending.height orelse switch (toplevel.configure_state) {
+    const height: u31 = scheduled.height orelse switch (toplevel.configure_state) {
         .idle => @intCast(toplevel.geometry.height),
-        .timed_out, .timed_out_acked => toplevel.window.sent.height.?,
+        .timed_out, .timed_out_acked => toplevel.window.configure_sent.height.?,
         .inflight, .acked, .committed => unreachable,
     };
     const configure_serial = wlr_toplevel.setSize(width, height);
 
-    toplevel.window.sent = toplevel.window.pending;
-    toplevel.window.sent.width = width;
-    toplevel.window.sent.height = height;
-    toplevel.window.pending.width = null;
-    toplevel.window.pending.height = null;
+    toplevel.window.configure_sent = toplevel.window.configure_scheduled;
+    toplevel.window.configure_sent.width = width;
+    toplevel.window.configure_sent.height = height;
+    toplevel.window.configure_scheduled.width = null;
+    toplevel.window.configure_scheduled.height = null;
 
     toplevel.configure_state = .{
         .inflight = configure_serial,
@@ -194,18 +194,18 @@ pub fn configure(toplevel: *XdgToplevel) bool {
 }
 
 fn needsConfigure(toplevel: *XdgToplevel) bool {
-    const pending = &toplevel.window.pending;
-    const sent = &toplevel.window.sent;
-
-    if (pending.width != null) return true;
-    if (pending.height != null) return true;
-    if (pending.activated != sent.activated) return true;
-    if (pending.ssd != sent.ssd) return true;
-    if (!std.meta.eql(pending.tiled, sent.tiled)) return true;
-    if (!std.meta.eql(pending.capabilities, sent.capabilities)) return true;
-    if (pending.maximized != sent.maximized) return true;
-    if (pending.fullscreen != sent.fullscreen) return true;
-    if ((pending.op == .resize) != (sent.op == .resize)) return true;
+    const scheduled = &toplevel.window.configure_scheduled;
+    const sent = &toplevel.window.configure_sent;
+
+    if (scheduled.width != null) return true;
+    if (scheduled.height != null) return true;
+    if (scheduled.activated != sent.activated) return true;
+    if (scheduled.ssd != sent.ssd) return true;
+    if (!std.meta.eql(scheduled.tiled, sent.tiled)) return true;
+    if (!std.meta.eql(scheduled.capabilities, sent.capabilities)) return true;
+    if (scheduled.maximized != sent.maximized) return true;
+    if (scheduled.fullscreen != sent.fullscreen) return true;
+    if ((scheduled.resizing) != (sent.resizing)) return true;
 
     return false;
 }
@@ -298,9 +298,9 @@ fn handleCommit(listener: *wl.Listener(*wlr.Surface), _: *wlr.Surface) void {
     });
 
     if (toplevel.wlr_toplevel.base.initial_commit) {
-        assert(window.wm_pending.state != .ready);
-        window.wm_pending.state = .ready;
-        server.wm.dirtyPending();
+        assert(window.windowing_scheduled.state != .ready);
+        window.windowing_scheduled.state = .ready;
+        server.wm.dirtyWindowing();
         return;
     }
 
@@ -322,13 +322,12 @@ fn handleCommit(listener: *wl.Listener(*wlr.Surface), _: *wlr.Surface) void {
                     .{ old_geometry.width, old_geometry.height, toplevel.geometry.width, toplevel.geometry.height },
                 );
 
-                window.setDimensions(toplevel.geometry.width, toplevel.geometry.height);
-                window.updateSceneState();
+                window.setDimensions(@intCast(toplevel.geometry.width), @intCast(toplevel.geometry.height));
             } else if (old_geometry.x != toplevel.geometry.x or
                 old_geometry.y != toplevel.geometry.y)
             {
                 // We need to update the surface clip box to reflect the geometry change.
-                window.updateSceneState();
+                // XXX actually update the clip box
             }
         },
         // If the client has not yet acked our configure, we need to send a
@@ -339,7 +338,8 @@ fn handleCommit(listener: *wl.Listener(*wlr.Surface), _: *wlr.Surface) void {
         .acked, .timed_out_acked => {
             toplevel.wlr_toplevel.base.getGeometry(&toplevel.geometry);
 
-            window.setDimensions(toplevel.geometry.width, toplevel.geometry.height);
+            window.rendering_scheduled.width = @intCast(toplevel.geometry.width);
+            window.rendering_scheduled.height = @intCast(toplevel.geometry.height);
 
             switch (toplevel.configure_state) {
                 .acked => {
@@ -348,7 +348,7 @@ fn handleCommit(listener: *wl.Listener(*wlr.Surface), _: *wlr.Surface) void {
                 },
                 .timed_out_acked => {
                     toplevel.configure_state = .idle;
-                    window.updateSceneState();
+                    server.wm.dirtyRendering();
                 },
                 else => unreachable,
             }
@@ -371,7 +371,7 @@ fn handleRequestMove(
 
     // Moving windows with touch or tablet tool is not yet supported.
     if (seat.wlr_seat.validatePointerGrabSerial(null, event.serial)) {
-        // XXX queue pointer_move_requested, dirtyPending()
+        // XXX queue pointer_move_requested, dirtyWindowing()
     }
 }
 
@@ -382,7 +382,7 @@ fn handleRequestResize(listener: *wl.Listener(*wlr.XdgToplevel.event.Resize), ev
 
     // Resizing windows with touch or tablet tool is not yet supported.
     if (seat.wlr_seat.validatePointerGrabSerial(null, event.serial)) {
-        // XXX queue pointer_resize_requested, dirtyPending()
+        // XXX queue pointer_resize_requested, dirtyWindowing()
     }
 }
 
diff --git a/river/XkbBinding.zig b/river/XkbBinding.zig
index f9644e0..4df4fa7 100644
--- a/river/XkbBinding.zig
+++ b/river/XkbBinding.zig
@@ -32,28 +32,25 @@ const Seat = @import("Seat.zig");
 
 const log = std.log.scoped(.input);
 
-const WmState = struct {
-    enabled: bool = false,
-    // This is set for mappings with layout-pinning
-    // If set, the layout with this index is always used to translate the given keycode
-    layout: ?u32 = null,
-};
-
 seat: *Seat,
 object: *river.XkbBindingV1,
 
 keysym: xkb.Keysym,
 modifiers: river.SeatV1.Modifiers,
 
-pending: struct {
+windowing_scheduled: struct {
     state_change: enum {
         none,
         pressed,
         released,
     } = .none,
 } = .{},
-uncommitted: WmState = .{},
-committed: WmState = .{},
+windowing_requested: struct {
+    enabled: bool = false,
+    // This is set for mappings with layout-pinning
+    // If set, the layout with this index is always used to translate the given keycode
+    layout: ?u32 = null,
+} = .{},
 
 /// This bit of state is used to ensure that multiple simultaneous
 /// presses across multiple keyboards do not cause multiple press
@@ -125,9 +122,18 @@ fn handleRequest(
     assert(binding.object == xkb_binding_v1);
     switch (request) {
         .destroy => xkb_binding_v1.destroy(),
-        .set_layout_override => |args| binding.uncommitted.layout = args.layout,
-        .enable => binding.uncommitted.enabled = true,
-        .disable => binding.uncommitted.enabled = false,
+        .set_layout_override => |args| {
+            // XXX protocol error?
+            binding.windowing_requested.layout = args.layout;
+        },
+        .enable => {
+            if (!server.wm.ensureWindowing()) return;
+            binding.windowing_requested.enabled = true;
+        },
+        .disable => {
+            if (!server.wm.ensureWindowing()) return;
+            binding.windowing_requested.enabled = false;
+        },
     }
 }
 
@@ -135,18 +141,18 @@ pub fn pressed(binding: *XkbBinding) void {
     assert(!binding.sent_pressed);
     // Input event processing should not continue after a press/release event
     // until that event is sent to the window manager in an update and acked.
-    assert(binding.pending.state_change == .none);
-    binding.pending.state_change = .pressed;
-    server.wm.dirtyPending();
+    assert(binding.windowing_scheduled.state_change == .none);
+    binding.windowing_scheduled.state_change = .pressed;
+    server.wm.dirtyWindowing();
 }
 
 pub fn released(binding: *XkbBinding) void {
     assert(binding.sent_pressed);
     // Input event processing should not continue after a press/release event
     // until that event is sent to the window manager in an update and acked.
-    assert(binding.pending.state_change == .none);
-    binding.pending.state_change = .released;
-    server.wm.dirtyPending();
+    assert(binding.windowing_scheduled.state_change == .none);
+    binding.windowing_scheduled.state_change = .released;
+    server.wm.dirtyWindowing();
 }
 
 /// Compare binding with given keycode, modifiers and keyboard state
@@ -157,14 +163,14 @@ pub fn match(
     xkb_state: *xkb.State,
     method: enum { no_translate, translate },
 ) bool {
-    if (!binding.committed.enabled) return false;
+    if (!binding.windowing_requested.enabled) return false;
 
     const keymap = xkb_state.getKeymap();
 
     // If the binding has no pinned layout, use the active layout.
     // It doesn't matter if the index is out of range, since xkbcommon
     // will fall back to the active layout if so.
-    const layout = binding.committed.layout orelse xkb_state.keyGetLayout(keycode);
+    const layout = binding.windowing_requested.layout orelse xkb_state.keyGetLayout(keycode);
 
     switch (method) {
         .no_translate => {
diff --git a/river/XwaylandOverrideRedirect.zig b/river/XwaylandOverrideRedirect.zig
index 6a6ae1a..70ec3b1 100644
--- a/river/XwaylandOverrideRedirect.zig
+++ b/river/XwaylandOverrideRedirect.zig
@@ -180,7 +180,7 @@ fn handleUnmap(listener: *wl.Listener(void)) void {
         }
     }
 
-    server.wm.dirtyPending();
+    server.wm.dirtyWindowing();
 }
 
 fn handleSetGeometry(listener: *wl.Listener(void)) void {
diff --git a/river/XwaylandWindow.zig b/river/XwaylandWindow.zig
index d52775a..7f46a17 100644
--- a/river/XwaylandWindow.zig
+++ b/river/XwaylandWindow.zig
@@ -99,10 +99,10 @@ pub fn configure(xwindow: *XwaylandWindow) bool {
 
     // Sending a 0 width/height to X11 clients is invalid, so fake it
     if (inflight.box.width == 0) {
-        inflight.box.width = xwindow.window.pending.box.width;
+        inflight.box.width = xwindow.window.configure_scheduled.box.width;
     }
     if (inflight.box.height == 0) {
-        inflight.box.height = xwindow.window.pending.box.height;
+        inflight.box.height = xwindow.window.configure_scheduled.box.height;
     }
 
     if (inflight.hidden != current.hidden) {
@@ -194,14 +194,14 @@ pub fn handleMap(listener: *wl.Listener(void)) void {
     };
 
     // XXX this seems like it should be deleted/moved to handleCommit()
-    window.pending.box = .{
+    window.configure_scheduled.box = .{
         .x = 0,
         .y = 0,
         .width = xwindow.xsurface.width,
         .height = xwindow.xsurface.height,
     };
-    window.inflight.box = window.pending.box;
-    window.current.box = window.pending.box;
+    window.inflight.box = window.configure_scheduled.box;
+    window.current.box = window.configure_scheduled.box;
 
     window.map() catch {
         log.err("out of memory", .{});
diff --git a/rivercompat/Window.zig b/rivercompat/Window.zig
index d25f8e2..c360091 100644
--- a/rivercompat/Window.zig
+++ b/rivercompat/Window.zig
@@ -106,6 +106,9 @@ pub fn updateWindowing(window: *Window, wm: *WindowManager) void {
                 seat.focus(window);
             }
         }
+
+        window.node_v1.setPosition(20, 20);
+        window.window_v1.proposeDimensions(400, 400);
     }
 
     window.pending = .{};
diff --git a/rivercompat/WindowManager.zig b/rivercompat/WindowManager.zig
index 83fac77..d863f93 100644
--- a/rivercompat/WindowManager.zig
+++ b/rivercompat/WindowManager.zig
@@ -73,7 +73,7 @@ fn handleEvent(wm_v1: *river.WindowManagerV1, event: river.WindowManagerV1.Event
         },
         .update_rendering_start => {
             wm.updateRendering();
-            wm_v1.updateWindowingFinish();
+            wm_v1.updateRenderingFinish();
         },
         .session_locked => {},
         .session_unlocked => {},
@@ -95,16 +95,9 @@ fn updateWindowing(wm: *WindowManager) void {
         }
     }
     {
-        var x: i32 = 0;
-        var y: i32 = 0;
         var it = wm.windows.iterator(.forward);
         while (it.next()) |window| {
             window.updateWindowing(wm);
-
-            window.node_v1.setPosition(x, y);
-            window.window_v1.proposeDimensions(400, 400);
-            x += 40;
-            y += 40;
         }
     }
     {