diff --git a/.changeset/remove-component-set-undefined.md b/.changeset/remove-component-set-undefined.md new file mode 100644 index 00000000..06be2143 --- /dev/null +++ b/.changeset/remove-component-set-undefined.md @@ -0,0 +1,9 @@ +--- +"miniplex": patch +--- + +`World.removeComponent` now assigns `undefined` to the removed component instead of using the `delete` operator. + +Queries already treat a component as absent when its value is `undefined`, so this is fully transparent to `with`/`without`/`where` queries. Assigning `undefined` keeps the entity's object shape stable (avoiding V8 deoptimizations — measurably faster component removal and post-removal iteration) and routes the change through any property setters/proxies, making miniplex compatible with reactive/observed objects such as [Colyseus](https://colyseus.io) schemas. + +Note: the property key is now retained on the entity (with value `undefined`) rather than removed, so checks that rely on key presence (e.g. `"foo" in entity` or `Object.keys(entity)`) will continue to see it. diff --git a/packages/core/benchmark.ts b/packages/core/benchmark.ts index dc5266e9..2e2b0efb 100644 --- a/packages/core/benchmark.ts +++ b/packages/core/benchmark.ts @@ -160,6 +160,73 @@ profile("clear (with archetypes)", () => { } }) +heading("Component Removal") + +profile("removeComponent", () => { + const world = new World() + + for (let i = 0; i < entityCount; i++) + world.add({ + position: { x: 0, y: i, z: 0 }, + velocity: { x: 0, y: 0, z: 0 } + }) + + return () => { + for (const entity of world.entities) { + world.removeComponent(entity, "velocity") + } + + return () => world.entities.every((e) => e.velocity === undefined) + } +}) + +profile("removeComponent (with archetypes)", () => { + const world = new World() + const withVelocity = world.with("velocity").connect() + + for (let i = 0; i < entityCount; i++) + world.add({ + position: { x: 0, y: i, z: 0 }, + velocity: { x: 0, y: 0, z: 0 } + }) + + return () => { + for (const entity of world.entities) { + world.removeComponent(entity, "velocity") + } + + return () => withVelocity.size === 0 + } +}) + +/* Iterate entities after a component was removed, surfacing any object-shape + deoptimization from the removal. */ +profile("simulate after removeComponent", () => { + const world = new World() + + for (let i = 0; i < entityCount; i++) + world.add({ + position: { x: Math.random() * 200 - 100, y: i, z: 0 }, + velocity: { x: 1, y: 2, z: 3 } + }) + + for (const entity of world.entities) { + world.removeComponent(entity, "velocity") + } + + return () => { + let i = 0 + for (const { position } of world.entities) { + i++ + position.x += 1 + position.y += 1 + position.z += 1 + } + + return () => i === entityCount + } +}) + heading("Iteration") profile("simulate (iterator, world)", () => { diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 89f592fb..0ce10d27 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -146,7 +146,7 @@ export class World * @param entity The entity to modify. * @param component The name of the component to remove. */ - removeComponent(entity: E, component: keyof E) { + removeComponent(entity: E, component: C) { /* Return early if the entity doesn't even have the component. */ if (entity[component] === undefined) return @@ -158,8 +158,10 @@ export class World this.reindex(entity, future) } - /* Remove the component. */ - delete entity[component] + /* Assign `undefined` rather than `delete`: queries treat both as absent, but + this keeps the object shape stable and works with proxied property access + (e.g. Colyseus schemas). */ + entity[component] = undefined as E[C] } /* QUERIES */ diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index 436640ff..143e9a8d 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -375,6 +375,14 @@ describe(World, () => { expect(entity).toEqual({ name: "John" }) }) + it("sets the component to `undefined` instead of `delete`-ing the property", () => { + const world = new World() + const entity = world.add({ name: "John", age: 30 }) + world.removeComponent(entity, "age") + expect(entity.age).toBeUndefined() + expect("age" in entity).toBe(true) + }) + it("removes the entity from any relevant archetypes", () => { const world = new World() const query = world.with("age").without("height")