From 18dbdf8a585e552c6768affe7e9d8603a2f874a0 Mon Sep 17 00:00:00 2001 From: Endel Dreyer Date: Fri, 29 May 2026 16:58:07 -0300 Subject: [PATCH] fix: assign undefined instead of delete in removeComponent Queries already treat a component as absent when its value is undefined, so this is transparent to with/without/where. Avoiding delete keeps the entity's object shape stable (faster removal and iteration) and routes the change through proxied property access such as Colyseus schemas. The property key is now retained with value undefined rather than removed. Assisted-by: Claude Opus 4.8 --- .changeset/remove-component-set-undefined.md | 9 +++ packages/core/benchmark.ts | 67 ++++++++++++++++++++ packages/core/src/core.ts | 8 ++- packages/core/test/core.test.ts | 8 +++ 4 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 .changeset/remove-component-set-undefined.md 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")