Lesson 10
What the command queue merges away
When a system queues several operations for one entity, flecs applies them as a batch. Which of those operations observers get to see, and how that changed on master in October 2026.
The commands lesson showed that operations inside a system are queued, and that commands for the same entity are batched: the first command for an entity applies all of them at once, so it moves once, to its final table. That’s great for performance. It also means the intermediate states in your code never exist in storage, so observers can’t see them.
That raises some questions. What happens when the queue says remove, then add the same component? Does an OnRemove observer see the value you set between two removes? Until October 2026 the answers were surprising. A fix to remove/add command batching (commit 9062901, “Fix deferred remove of overridden components and remove/add command batching”) changed them, and it is on master now.
The debugger below can run both builds: current master, and master from just before the fix (68631db). Each line runs a system once on its own entity (A to D, each starting with Foo{40}), and three observers record what they see. Step through it, then switch the build at the top and compare.
Loading the flecs debugger…
What each build does
Observer calls are shown with the value of Foo they were given.
| Case (queued in one system) | Before the fix (68631db) |
flecs master (9062901 and later) |
|---|---|---|
A remove<Foo>(), add<Foo>() |
OnAdd(40) only. No move, and Foo keeps 40. There’s no OnRemove, yet OnAdd fires for a component that never left. |
OnRemove(40), then OnAdd(0). A really leaves and re-enters the table, so Foo is constructed fresh: 0. |
B remove, set<Foo>({50}), remove |
OnRemove(50) twice. The 40 that was really removed is never seen. |
OnRemove(40) once. The 50 never existed, so nobody sees it. |
C remove, set<Foo>({50}) |
OnAdd(50), OnSet(50). The old 40 is overwritten without an OnRemove. |
OnRemove(40), OnAdd(0), OnSet(50): a full remove, then a fresh add and set. |
D set<Foo>({50}), remove |
OnRemove(50) |
OnRemove(50) |
So on current master the rule is simple. The command queue applies your operations in order. Batching only skips work that can’t be observed, and never invents or hides events. A queued remove really removes, so the component’s OnRemove sees the value that was in storage, and a later add constructs a new value. A value that only ever existed inside the queue (B’s 50) is never visible to anyone.
Before the fix, batching folded remove/add pairs together in ways that produced events that didn’t match what happened: an OnAdd with no OnRemove, or two OnRemoves for one removal reporting a value that was never stored.