Document delete flag in OnRemove hooks

This commit is contained in:
Ukendio 2025-12-28 12:35:08 +01:00
parent e4b12f4a28
commit d5c9abc57f
4 changed files with 69 additions and 9 deletions

View file

@ -16,8 +16,8 @@ local jecs = require("@jecs")
--[[
There are two ways to create tags:
1. Using jecs.tag() - preregister a tag entity which will be allocated when you create the world.
2. Using world:entity() - creates a regular entity id
1. Using jecs.tag() to preregister a tag entity which will be allocated when you create the world.
2. Using world:entity() to create a regular entity id
The first method is the "proper" way to create tags but it hinges upon that
you do remember to create the world after declaring all of your

View file

@ -79,6 +79,6 @@ end
-- Cached query (faster for repeated use)
local cached_query = world:query(Position, Velocity):cached()
for entity, pos, vel in cached_query do
-- Process entities - this is faster for repeated iterations
-- Process entities. This is faster for repeated iterations.
end

View file

@ -17,8 +17,9 @@ world:set(Transform, jecs.OnAdd, function(entity, id, data)
print(`Transform added to entity {entity}`)
end)
world:set(Transform, jecs.OnRemove, function(entity, id)
world:set(Transform, jecs.OnRemove, function(entity, id, delete)
-- A transform component id has been removed from entity
-- delete is true if the entity is being deleted, false/nil otherwise
print(`Transform removed from entity {entity}`)
end)
@ -37,9 +38,67 @@ end)
When an entity graph contains cycles, order is undefined. This includes cycles
that can be formed using different relationships.
However an important note to make is that structural changes are not
necessarily always safe in OnRemove hooks. For instance, when an entity is
being deleted and invokes all of the OnRemove hooks on its components. It
can cause a lot of issues with moving entities
]]
--[[
Structural changes in OnRemove hooks
You can call world:add, world:remove, and world:set inside OnRemove hooks.
That's fine. But there's a catch.
When an entity is being deleted, all of its components get removed. Each
removal triggers the OnRemove hook. If you try to make structural changes
to the entity during deletion, like removing more components or adding new
ones, you're fighting against the deletion process itself. The entity is
going to lose all its components anyway, so what's the point?
This creates a conflict. On one hand, you might want to clean up related
components when a specific component is removed. On the other hand, during
deletion, you don't want to do that because the entity is already being
torn down. So you need a way to tell the difference.
The solution is the delete boolean. Every OnRemove hook receives it as the
third parameter. It's true when the entity is being deleted, and false
(or nil) when you're just removing a single component normally.
So you check it. If delete is true, you bail out early. If it's false,
you do your cleanup. Simple.
Here's what it looks like in practice:
]]
local Health = world:component()
local Dead = world:component()
world:set(Health, jecs.OnRemove, function(entity, id, delete)
if delete then
-- Entity is being deleted, don't try to clean up
return
end
-- Normal removal, do cleanup
world:remove(entity, Dead)
end)
--[[
The ob.luau module uses this pattern extensively. When you're building
observers or monitors that track component removals, you need to distinguish
between "component removed" and "entity deleted" because they mean different
things for your tracking logic.
Now, about the DEBUG flag. If you create a world with DEBUG enabled:
local world = jecs.world(true)
Then the world will actively prevent you from calling world:add, world:remove,
or world:set inside OnRemove hooks when delete is true. It throws an error
that tells you exactly what went wrong. This is useful during development
to catch cases where you forgot to check the delete flag.
But here's the important part: even with DEBUG enabled, you're still allowed
to call these functions when delete is false. The DEBUG mode only prevents
structural changes during deletion, not during normal component removal.
So the pattern is always the same: check delete, bail if true, proceed if false.
The DEBUG flag just makes sure you don't forget to do the check.
]]

View file

@ -3748,6 +3748,7 @@ local function world_new(DEBUG: boolean?)
DEBUG_DELETING_ENTITY = entity
DEBUG_IS_INVALID_ENTITY(entity)
canonical_world_delete(world, entity, id)
DEBUG_DELETING_ENTITY = nil
end
world_delete = world_delete_checked