From 72e2660f3d1da370a62e5651eb11177507e1a3a2 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 14 Jul 2026 10:15:44 -0700 Subject: [PATCH 1/4] fix(data): observe.select re-emits on membership contraction observeSelectEntities did not re-emit when a transaction removed an entity from the result set by clearing a required (membership) component that the caller selected but did not filter or order on. The in-set branch of the per-transaction check only re-tested `order` and `where` components; it never re-derived archetype membership, so a removed `include` component migrated the row out of the result archetype silently and the observer kept emitting a stale list. Add the symmetric counterpart to the existing entry-path check: when an entity currently in the set has an `include` component changed, re-derive membership from the authoritative store (`store.locate` + `isSupersetOf(includeSet)`) and re-emit if it has left the set. `whereSet` is a subset of `includeSet`, so the existing where-predicate re-check is folded into the same single locate. Preserves O(changes): the loop still iterates only changed entities, with at most one O(1) locate each; value-only transactions still hit the early-exit without notifying. A full O(result-rows) re-query happens only on a genuine membership/order/where change. Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- packages/data-ai/package.json | 2 +- packages/data-gpu-samples/package.json | 2 +- packages/data-gpu/package.json | 2 +- packages/data-lit-tictactoe/package.json | 2 +- packages/data-lit-todo/package.json | 2 +- packages/data-lit/package.json | 2 +- packages/data-p2p-tictactoe/package.json | 2 +- packages/data-persistence/package.json | 2 +- packages/data-react-hello/package.json | 2 +- packages/data-react-pixie/package.json | 2 +- packages/data-react/package.json | 2 +- packages/data-solid-dashboard/package.json | 2 +- packages/data-solid/package.json | 2 +- packages/data-sync/package.json | 2 +- packages/data/package.json | 2 +- .../database/observe-select-entities.test.ts | 50 +++++++++++++++++++ .../ecs/database/observe-select-entities.ts | 23 ++++++--- 18 files changed, 83 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 00842545..f20c73f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-monorepo", - "version": "0.9.78", + "version": "0.9.79", "private": true, "scripts": { "build": "pnpm -r run build", diff --git a/packages/data-ai/package.json b/packages/data-ai/package.json index fea62303..58850c04 100644 --- a/packages/data-ai/package.json +++ b/packages/data-ai/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-ai", - "version": "0.9.78", + "version": "0.9.79", "description": "Cross-agent architecture skills for @adobe/data — installable as a Claude Code plugin or copied into any Agent-Skills-compatible agent (Cursor, Codex).", "type": "module", "private": false, diff --git a/packages/data-gpu-samples/package.json b/packages/data-gpu-samples/package.json index 207f4c12..209809c6 100644 --- a/packages/data-gpu-samples/package.json +++ b/packages/data-gpu-samples/package.json @@ -1,6 +1,6 @@ { "name": "data-gpu-samples", - "version": "0.9.78", + "version": "0.9.79", "description": "WebGPU samples built on @adobe/data-gpu", "type": "module", "private": true, diff --git a/packages/data-gpu/package.json b/packages/data-gpu/package.json index 6c70f43b..217be77e 100644 --- a/packages/data-gpu/package.json +++ b/packages/data-gpu/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-gpu", - "version": "0.9.78", + "version": "0.9.79", "description": "Adobe data WebGPU plugins and types for graphics and compute", "type": "module", "private": false, diff --git a/packages/data-lit-tictactoe/package.json b/packages/data-lit-tictactoe/package.json index 860a58c7..850ad4fe 100644 --- a/packages/data-lit-tictactoe/package.json +++ b/packages/data-lit-tictactoe/package.json @@ -1,6 +1,6 @@ { "name": "data-lit-tictactoe", - "version": "0.9.78", + "version": "0.9.79", "description": "Tic-Tac-Toe sample - Lit web components with @adobe/data-lit and AgenticService", "type": "module", "private": true, diff --git a/packages/data-lit-todo/package.json b/packages/data-lit-todo/package.json index 935046eb..03364236 100644 --- a/packages/data-lit-todo/package.json +++ b/packages/data-lit-todo/package.json @@ -1,6 +1,6 @@ { "name": "data-lit-todo", - "version": "0.9.78", + "version": "0.9.79", "description": "Todo sample app demonstrating @adobe/data with Lit", "type": "module", "private": true, diff --git a/packages/data-lit/package.json b/packages/data-lit/package.json index 516f87e8..5300f708 100644 --- a/packages/data-lit/package.json +++ b/packages/data-lit/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-lit", - "version": "0.9.78", + "version": "0.9.79", "description": "Adobe data Lit bindings - hooks, elements, decorators", "type": "module", "private": false, diff --git a/packages/data-p2p-tictactoe/package.json b/packages/data-p2p-tictactoe/package.json index 3eaf5515..6f0c5ac4 100644 --- a/packages/data-p2p-tictactoe/package.json +++ b/packages/data-p2p-tictactoe/package.json @@ -1,6 +1,6 @@ { "name": "data-p2p-tictactoe", - "version": "0.9.78", + "version": "0.9.79", "description": "Serverless P2P tic-tac-toe — WebRTC DataChannel + @adobe/data-sync", "type": "module", "private": true, diff --git a/packages/data-persistence/package.json b/packages/data-persistence/package.json index 28f86716..54e53bf6 100644 --- a/packages/data-persistence/package.json +++ b/packages/data-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-persistence", - "version": "0.9.78", + "version": "0.9.79", "description": "Worker-based incremental persistence layer for @adobe/data ECS over OPFS (browser) and node:fs (server).", "type": "module", "sideEffects": false, diff --git a/packages/data-react-hello/package.json b/packages/data-react-hello/package.json index 64fcead5..d236e0bd 100644 --- a/packages/data-react-hello/package.json +++ b/packages/data-react-hello/package.json @@ -1,6 +1,6 @@ { "name": "data-react-hello", - "version": "0.9.78", + "version": "0.9.79", "description": "Hello World sample - click counter using @adobe/data-react", "type": "module", "private": true, diff --git a/packages/data-react-pixie/package.json b/packages/data-react-pixie/package.json index 433cd9a8..e71020b3 100644 --- a/packages/data-react-pixie/package.json +++ b/packages/data-react-pixie/package.json @@ -1,6 +1,6 @@ { "name": "data-react-pixie", - "version": "0.9.78", + "version": "0.9.79", "description": "PixiJS React sample - ECS sprites (bunny, fox) with @adobe/data-react", "type": "module", "private": true, diff --git a/packages/data-react/package.json b/packages/data-react/package.json index 519137dd..205aa0f5 100644 --- a/packages/data-react/package.json +++ b/packages/data-react/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-react", - "version": "0.9.78", + "version": "0.9.79", "description": "Adobe data React bindings — hooks and context for ECS database", "type": "module", "private": false, diff --git a/packages/data-solid-dashboard/package.json b/packages/data-solid-dashboard/package.json index 09f8b163..62b3e662 100644 --- a/packages/data-solid-dashboard/package.json +++ b/packages/data-solid-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "data-solid-dashboard", - "version": "0.9.78", + "version": "0.9.79", "description": "Mini dashboard sample — multiple components sharing one @adobe/data ECS database with SolidJS", "type": "module", "private": true, diff --git a/packages/data-solid/package.json b/packages/data-solid/package.json index 4d70b0c2..ae2cfff9 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-solid", - "version": "0.9.78", + "version": "0.9.79", "description": "Adobe data SolidJS bindings — context and provider for ECS database", "type": "module", "private": false, diff --git a/packages/data-sync/package.json b/packages/data-sync/package.json index 6778635b..f328cebd 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-sync", - "version": "0.9.78", + "version": "0.9.79", "description": "Multi-user real-time synchronisation for @adobe/data ECS — server, client, and in-process loopback.", "type": "module", "sideEffects": false, diff --git a/packages/data/package.json b/packages/data/package.json index d8561241..ae38e2c9 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data", - "version": "0.9.78", + "version": "0.9.79", "description": "Adobe data oriented programming library", "type": "module", "sideEffects": false, diff --git a/packages/data/src/ecs/database/observe-select-entities.test.ts b/packages/data/src/ecs/database/observe-select-entities.test.ts index 599d6b7e..4e0c5b21 100644 --- a/packages/data/src/ecs/database/observe-select-entities.test.ts +++ b/packages/data/src/ecs/database/observe-select-entities.test.ts @@ -312,6 +312,56 @@ describe("observeSelectEntities", () => { unsubscribe(); }); + it("should notify when a required component is removed, dropping the entity from the result set", async () => { + const observer = vi.fn(); + // Select on position+health but NOT filtered/ordered on either. This is the + // membership-contraction case: removing `health` migrates the row to the + // position-only archetype, so it leaves the result set even though the + // caller never filters or orders on health. + const unsubscribe = database.observe.select(["position", "health"])(observer); + + expect(observer).toHaveBeenCalledTimes(1); + expect(observer.mock.calls[0][0]).toContain(entities.posHealth1); + + // Remove the `health` component (undefined => column removal) so the entity + // migrates out of the position+health archetype. + database.transactions.updateEntity({ + entity: entities.posHealth1, + values: { health: undefined } + }); + + await Promise.resolve(); + + // Must re-emit without the entity that left the result set. + expect(observer).toHaveBeenCalledTimes(2); + expect(observer.mock.calls[1][0]).not.toContain(entities.posHealth1); + + unsubscribe(); + }); + + it("should NOT notify when removing a component the query does not select", async () => { + const observer = vi.fn(); + // Selecting only position: removing `health` from a position+health entity + // keeps it a member (its archetype is still a superset of {position}), so + // no re-emit should occur — the O(changes) early-exit must still fire. + const unsubscribe = database.observe.select(["position"])(observer); + + expect(observer).toHaveBeenCalledTimes(1); + expect(observer.mock.calls[0][0]).toContain(entities.posHealth1); + + database.transactions.updateEntity({ + entity: entities.posHealth1, + values: { health: undefined } + }); + + await Promise.resolve(); + + // Still a member => no new notification. + expect(observer).toHaveBeenCalledTimes(1); + + unsubscribe(); + }); + it("should handle entity updates", async () => { const observer = vi.fn(); const unsubscribe = database.observe.select(["position", "health"])(observer); diff --git a/packages/data/src/ecs/database/observe-select-entities.ts b/packages/data/src/ecs/database/observe-select-entities.ts index 7727e016..8bcc8e9c 100644 --- a/packages/data/src/ecs/database/observe-select-entities.ts +++ b/packages/data/src/ecs/database/observe-select-entities.ts @@ -56,12 +56,23 @@ export const observeSelectEntities = (store: ReadonlyStore Date: Tue, 14 Jul 2026 10:34:14 -0700 Subject: [PATCH 2/4] perf(data): O(1) memoized archetype membership + exclude reactivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-entity O(|include|) `isSupersetOf(include)` scan in the observe.select transaction handler with a `Map` memoized per subscription. Archetype component sets never change and ids are stable/dense, so each archetype is classified at most once; every subsequent entity in it is an O(1) map lookup. Archetypes created after subscription are classified on first sight, so late migrations into a brand-new qualifying archetype are still detected. Unify the in-set and entry branches through the shared `qualifies` helper. This also fixes a latent staleness bug: the reactive path previously ignored the `exclude` clause entirely (only the initial `store.select` honored it), so an entity gaining or losing an excluded component never re-emitted. The top-level guard and the per-entity gate now test `include ∪ exclude`; when there is no exclude clause the extra `isDisjointFrom(∅)` is a free O(1) check, preserving the fast path. Still O(changes): the loop iterates only changed entities, each costing one O(1) locate + O(1) memoized lookup; value-only transactions still hit the early-exit without notifying. Co-Authored-By: Claude Opus 4.8 --- .../database/observe-select-entities.test.ts | 69 ++++++++++++ .../ecs/database/observe-select-entities.ts | 105 +++++++++++------- 2 files changed, 131 insertions(+), 43 deletions(-) diff --git a/packages/data/src/ecs/database/observe-select-entities.test.ts b/packages/data/src/ecs/database/observe-select-entities.test.ts index 4e0c5b21..2454f347 100644 --- a/packages/data/src/ecs/database/observe-select-entities.test.ts +++ b/packages/data/src/ecs/database/observe-select-entities.test.ts @@ -362,6 +362,75 @@ describe("observeSelectEntities", () => { unsubscribe(); }); + it("should drop an entity that gains an excluded component", async () => { + const observer = vi.fn(); + // Select position, excluding health. posName entities qualify; adding health + // to one must remove it from the result set even though `health` is neither + // an include nor a where/order key. + const unsubscribe = database.observe.select(["position"], { exclude: ["health"] })(observer); + + expect(observer).toHaveBeenCalledTimes(1); + expect(observer.mock.calls[0][0]).toContain(entities.posName1); + expect(observer.mock.calls[0][0]).not.toContain(entities.posHealth1); + + database.transactions.updateEntity({ + entity: entities.posName1, + values: { health: 50 } + }); + + await Promise.resolve(); + + expect(observer).toHaveBeenCalledTimes(2); + expect(observer.mock.calls[1][0]).not.toContain(entities.posName1); + + unsubscribe(); + }); + + it("should add an entity that loses an excluded component", async () => { + const observer = vi.fn(); + const unsubscribe = database.observe.select(["position"], { exclude: ["health"] })(observer); + + expect(observer).toHaveBeenCalledTimes(1); + expect(observer.mock.calls[0][0]).not.toContain(entities.posHealth1); + + // Remove health => posHealth1 becomes a position-only entity, now qualifying. + database.transactions.updateEntity({ + entity: entities.posHealth1, + values: { health: undefined } + }); + + await Promise.resolve(); + + expect(observer).toHaveBeenCalledTimes(2); + expect(observer.mock.calls[1][0]).toContain(entities.posHealth1); + + unsubscribe(); + }); + + it("should add an entity that migrates into a not-yet-seen archetype", async () => { + const observer = vi.fn(); + // Select position+score. Initially only Full entities qualify. Give a + // position-only entity a score, migrating it into a brand-new archetype + // (position+score) that did not exist when the subscription was created — + // the memoized classifier must recognise it on first sight. + const unsubscribe = database.observe.select(["position", "score"])(observer); + + expect(observer).toHaveBeenCalledTimes(1); + expect(observer.mock.calls[0][0]).not.toContain(entities.pos1); + + database.transactions.updateEntity({ + entity: entities.pos1, + values: { score: 42 } + }); + + await Promise.resolve(); + + expect(observer).toHaveBeenCalledTimes(2); + expect(observer.mock.calls[1][0]).toContain(entities.pos1); + + unsubscribe(); + }); + it("should handle entity updates", async () => { const observer = vi.fn(); const unsubscribe = database.observe.select(["position", "health"])(observer); diff --git a/packages/data/src/ecs/database/observe-select-entities.ts b/packages/data/src/ecs/database/observe-select-entities.ts index 8bcc8e9c..55824c1d 100644 --- a/packages/data/src/ecs/database/observe-select-entities.ts +++ b/packages/data/src/ecs/database/observe-select-entities.ts @@ -5,6 +5,7 @@ import { getRowPredicateFromFilter } from "../../table/select-rows.js"; import { StringKeyof } from "../../types/types.js"; import { RequiredComponents } from "../required-components.js"; import { Entity } from "../entity/entity.js"; +import { ArchetypeId, ReadonlyArchetype } from "../archetype/index.js"; import { OptionalComponents, ReadonlyStore } from "../index.js"; import { EntitySelectOptions } from "../store/entity-select-options.js"; import { TransactionResult } from "./transactional-store/transactional-store.js"; @@ -18,12 +19,31 @@ export const observeSelectEntities = (store: ReadonlyStore => { return (observer: (entities: readonly Entity[]) => void) => { const includeSet = new Set(include); + const excludeSet = new Set(options?.exclude ?? []); const whereSet = new Set(Object.keys(options?.where ?? {}) as StringKeyof[]); const orderSet = new Set(Object.keys(options?.order ?? {}) as StringKeyof[]); let isMicrotaskQueued = false; let currentEntities: Set | null = null; const rowPredicate = getRowPredicateFromFilter(options?.where); + // Whether an archetype qualifies for this query — its components are a + // superset of `include` and disjoint from `exclude` — is a pure function of + // the archetype's component set, which never changes once created, and + // archetype ids are stable and densely assigned. Memoize the verdict by id + // so the per-entity membership test is an O(1) map lookup rather than an + // O(|include|) superset scan: each archetype is classified at most once per + // subscription, and archetypes created later are classified on first sight. + const archetypeQualifies = new Map(); + const qualifies = (archetype: ReadonlyArchetype): boolean => { + let verdict = archetypeQualifies.get(archetype.id); + if (verdict === undefined) { + verdict = archetype.components.isSupersetOf(includeSet) + && (excludeSet.size === 0 || archetype.components.isDisjointFrom(excludeSet)); + archetypeQualifies.set(archetype.id, verdict); + } + return verdict; + }; + const notifyObsever = () => { // we just do a full select here. // later we will optimize this, since this algorithm is O(n) @@ -34,63 +54,62 @@ export const observeSelectEntities = (store: ReadonlyStore { - if (t.changedComponents.isDisjointFrom(includeSet)) { - // no components in the changed set are in the include set - // so we don't need to notify the observer. there is no possible change. + if (t.changedComponents.isDisjointFrom(includeSet) && t.changedComponents.isDisjointFrom(excludeSet)) { + // No changed component is selected or excluded. An entity can only + // enter or leave the result set by gaining/losing an `include` + // component or gaining/losing an `exclude` component, and `order`/ + // `where` keys are a subset of `include`, so neither the membership, + // the ordering, nor the filter outcome can have changed. (When there + // is no exclude clause `excludeSet` is empty and this extra test is a + // free O(1) `isDisjointFrom(∅)`.) return; } if (currentEntities) { let needsUpdate = false; for (const entity of t.changedEntities.keys()) { - if (currentEntities.has(entity)) { - const changedEntityValues = t.changedEntities.get(entity); - if (!changedEntityValues) { - // entity delete => update + const inSet = currentEntities.has(entity); + const changedEntityValues = t.changedEntities.get(entity); + if (!changedEntityValues) { + // hard delete: only matters if the entity was in the set. + if (inSet) { needsUpdate = true; break; } - const changedComponentSet = new Set(Object.keys(changedEntityValues) as StringKeyof[]); - if (!changedComponentSet.isDisjointFrom(orderSet)) { - // entity order components changed => update + continue; + } + const changedComponentSet = new Set(Object.keys(changedEntityValues) as StringKeyof[]); + if (includeSet.isDisjointFrom(changedComponentSet) && excludeSet.isDisjointFrom(changedComponentSet)) { + // No selected or excluded component changed for this entity, so + // its membership, order key, and filter value are all unchanged + // (`order`/`where` keys are a subset of `include`). + continue; + } + // A selected or excluded component changed for this entity; re-derive + // its membership from the authoritative store: one O(1) locate plus a + // memoized O(1) qualification lookup, no scan of the result set. + const location = store.locate(entity); + if (inSet) { + if (location === null || !qualifies(location.archetype)) { + // entity left the result set (deleted or migrated out — + // e.g. a required component was removed). needsUpdate = true; break; } - if (!includeSet.isDisjointFrom(changedComponentSet)) { - // An include component changed for this entity (whereSet is a - // subset of includeSet, so where changes are covered here too). - // The change may have migrated the row out of the result set by - // removing a required component, so re-derive membership from the - // authoritative store rather than assuming the entity is still a - // member. This is the symmetric counterpart to the entry check - // below, and keeps us O(changes): at most one O(1) locate per - // changed entity, no scan of the result set. - const location = store.locate(entity); - if (!location || !location.archetype.components.isSupersetOf(includeSet)) { - // entity has left the result set (membership contraction) - needsUpdate = true; - break; - } - if (!changedComponentSet.isDisjointFrom(whereSet) && !rowPredicate(location.archetype as any, location.row)) { - // a where component changed and the entity no longer matches the filter - needsUpdate = true; - break; - } + if (!changedComponentSet.isDisjointFrom(orderSet)) { + // an order key changed => the result ordering may change. + needsUpdate = true; + break; } - } - else { - // this entity is not in the set, we need to check it would be added to the result set. - const location = store.locate(entity); - if (location) { - const { archetype, row } = location; - if (archetype.components.isSupersetOf(includeSet)) { - if (rowPredicate(archetype as any, row)) { - // this entity would be in the result set. - needsUpdate = true; - break; - } - } + if (!changedComponentSet.isDisjointFrom(whereSet) && !rowPredicate(location.archetype as any, location.row)) { + // a filter value changed and the row no longer matches. + needsUpdate = true; + break; } + } else if (location !== null && qualifies(location.archetype) && rowPredicate(location.archetype as any, location.row)) { + // entity entered the result set. + needsUpdate = true; + break; } } if (!needsUpdate) { From 9e588cb6dc1a3b9e2719fc89abb3abc9f9b9fcdd Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 14 Jul 2026 10:38:40 -0700 Subject: [PATCH 3/4] refactor(data): replace `as any` on rowPredicate with a precise cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two `rowPredicate(location.archetype as any, ...)` calls discarded the whole table type. Localize them into one `matchesFilter` helper that asserts only the fuller `ReadonlyTable>` shape the predicate expects (`store.locate` types just the base `id` column). The invariant is real and compiler-invisible: callers reach it only after `qualifies` confirms the archetype is a superset of `include`, and `where` keys are a subset of `include`, so every column the predicate reads is present. Co-Authored-By: Claude Opus 4.8 --- .../data/src/ecs/database/observe-select-entities.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/data/src/ecs/database/observe-select-entities.ts b/packages/data/src/ecs/database/observe-select-entities.ts index 55824c1d..ae54e212 100644 --- a/packages/data/src/ecs/database/observe-select-entities.ts +++ b/packages/data/src/ecs/database/observe-select-entities.ts @@ -25,6 +25,13 @@ export const observeSelectEntities = (store: ReadonlyStore | null = null; const rowPredicate = getRowPredicateFromFilter(options?.where); + // Callers only reach this after `qualifies` has confirmed the archetype's + // components are a superset of `include`, and `where` keys are a subset of + // `include`, so the archetype carries every column the predicate reads. + // `store.locate` only types the base `id` column, so assert the fuller table + // shape the predicate expects (narrower than `as any`). + const matchesFilter = (archetype: ReadonlyArchetype, row: number) => + rowPredicate(archetype as Parameters[0], row); // Whether an archetype qualifies for this query — its components are a // superset of `include` and disjoint from `exclude` — is a pure function of @@ -101,12 +108,12 @@ export const observeSelectEntities = (store: ReadonlyStore Date: Tue, 14 Jul 2026 10:44:52 -0700 Subject: [PATCH 4/4] perf(data): short-circuit order-key changes before locate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An order-key change on an in-set entity always forces a requery — a row that stays a member may reorder, one that migrated out is removed — so the membership locate is unnecessary in that case. Move the order-key test ahead of store.locate to skip the lookup entirely for order-value churn (common in ordered/timeline queries). Behavior is unchanged; only the redundant locate is avoided. Co-Authored-By: Claude Opus 4.8 --- .../ecs/database/observe-select-entities.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/data/src/ecs/database/observe-select-entities.ts b/packages/data/src/ecs/database/observe-select-entities.ts index ae54e212..e2f178ce 100644 --- a/packages/data/src/ecs/database/observe-select-entities.ts +++ b/packages/data/src/ecs/database/observe-select-entities.ts @@ -86,10 +86,21 @@ export const observeSelectEntities = (store: ReadonlyStore[]); + if (inSet && !changedComponentSet.isDisjointFrom(orderSet)) { + // An order-key changed for a member. Whether the row stays a + // member (its sort value moved) or left the set, the result must + // be requeried — so short-circuit before the otherwise-needless + // locate. (`order` keys are a subset of `include`, so for a + // non-member this same change is instead an entry candidate, + // handled by the membership test below.) + needsUpdate = true; + break; + } if (includeSet.isDisjointFrom(changedComponentSet) && excludeSet.isDisjointFrom(changedComponentSet)) { // No selected or excluded component changed for this entity, so - // its membership, order key, and filter value are all unchanged - // (`order`/`where` keys are a subset of `include`). + // its membership is unchanged and — for an in-set row — its + // filter value is unchanged too (`where` keys are a subset of + // `include`). continue; } // A selected or excluded component changed for this entity; re-derive @@ -103,11 +114,6 @@ export const observeSelectEntities = (store: ReadonlyStore the result ordering may change. - needsUpdate = true; - break; - } if (!changedComponentSet.isDisjointFrom(whereSet) && !matchesFilter(location.archetype, location.row)) { // a filter value changed and the row no longer matches. needsUpdate = true;