From 4dccb2ed9131dcb09740633c325b1c4b11b80d6a Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 10 Oct 2026 00:31:46 -0700 Subject: [PATCH] fix(service): toObserveState emits without waiting for every member Unresolved members read as undefined (never valid Data); changes are batched to one emission per microtask. Bump to 0.10.27. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- packages/data-ai/.claude-plugin/plugin.json | 2 +- packages/data-ai/package.json | 2 +- packages/data-gpu/package.json | 2 +- packages/data-lit/package.json | 2 +- packages/data-persistence/package.json | 2 +- packages/data-react/package.json | 2 +- packages/data-rpc/package.json | 2 +- packages/data-solid/package.json | 2 +- packages/data-sync/package.json | 2 +- packages/data-testing/package.json | 2 +- packages/data/package.json | 2 +- .../src/service/async-data-service/README.md | 2 +- .../from-partial-properties.test.ts | 26 +++++++++++ .../from-partial-properties.ts | 43 +++++++++++++++++++ .../src/service/async-data-service/state.ts | 11 ++--- .../to-observe-state.test.ts | 27 ++++++------ .../async-data-service/to-observe-state.ts | 10 +++-- 18 files changed, 109 insertions(+), 34 deletions(-) create mode 100644 packages/data/src/service/async-data-service/from-partial-properties.test.ts create mode 100644 packages/data/src/service/async-data-service/from-partial-properties.ts diff --git a/package.json b/package.json index 4d79c4fa..1bab6bc0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-monorepo", - "version": "0.10.26", + "version": "0.10.27", "private": true, "engines": { "node": ">=24" diff --git a/packages/data-ai/.claude-plugin/plugin.json b/packages/data-ai/.claude-plugin/plugin.json index 17fe2e15..7ceb73ca 100644 --- a/packages/data-ai/.claude-plugin/plugin.json +++ b/packages/data-ai/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "adobe-data-ai", - "version": "0.10.26", + "version": "0.10.27", "description": "Architecture skills for @adobe/data — data-oriented modelling, archetype iteration, hot-path performance, and related conventions.", "author": { "name": "Adobe" diff --git a/packages/data-ai/package.json b/packages/data-ai/package.json index d60c419f..386236ad 100644 --- a/packages/data-ai/package.json +++ b/packages/data-ai/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-ai", - "version": "0.10.26", + "version": "0.10.27", "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/package.json b/packages/data-gpu/package.json index 469e933e..59b474b5 100644 --- a/packages/data-gpu/package.json +++ b/packages/data-gpu/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-gpu", - "version": "0.10.26", + "version": "0.10.27", "description": "Adobe data WebGPU plugins and types for graphics and compute", "type": "module", "private": false, diff --git a/packages/data-lit/package.json b/packages/data-lit/package.json index 22f63240..43d77f86 100644 --- a/packages/data-lit/package.json +++ b/packages/data-lit/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-lit", - "version": "0.10.26", + "version": "0.10.27", "description": "Adobe data Lit bindings - hooks, elements, decorators", "type": "module", "private": false, diff --git a/packages/data-persistence/package.json b/packages/data-persistence/package.json index 6078b192..8ab7cf54 100644 --- a/packages/data-persistence/package.json +++ b/packages/data-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-persistence", - "version": "0.10.26", + "version": "0.10.27", "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/package.json b/packages/data-react/package.json index 143a422e..2e4ca067 100644 --- a/packages/data-react/package.json +++ b/packages/data-react/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-react", - "version": "0.10.26", + "version": "0.10.27", "description": "Adobe data React bindings — hooks and context for ECS database", "type": "module", "private": false, diff --git a/packages/data-rpc/package.json b/packages/data-rpc/package.json index 8fd0c443..cc12eec3 100644 --- a/packages/data-rpc/package.json +++ b/packages/data-rpc/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-rpc", - "version": "0.10.26", + "version": "0.10.27", "description": "Schema-driven, bidirectional projection of @adobe/data async data services across a boundary (iframe / MessagePort / Worker). Only Data crosses the wire.", "type": "module", "sideEffects": false, diff --git a/packages/data-solid/package.json b/packages/data-solid/package.json index 6b3a88ab..54f3dc22 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-solid", - "version": "0.10.26", + "version": "0.10.27", "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 36b74a87..8305fa00 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-sync", - "version": "0.10.26", + "version": "0.10.27", "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-testing/package.json b/packages/data-testing/package.json index 373c5b91..51937b27 100644 --- a/packages/data-testing/package.json +++ b/packages/data-testing/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-testing", - "version": "0.10.26", + "version": "0.10.27", "description": "Conformance-testing utilities (Match + Conformance runners) for @adobe/data ECS features", "type": "module", "sideEffects": false, diff --git a/packages/data/package.json b/packages/data/package.json index 65b01fa8..d8070422 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data", - "version": "0.10.26", + "version": "0.10.27", "description": "Adobe data oriented programming library", "type": "module", "sideEffects": false, diff --git a/packages/data/src/service/async-data-service/README.md b/packages/data/src/service/async-data-service/README.md index 836250b1..c836c882 100644 --- a/packages/data/src/service/async-data-service/README.md +++ b/packages/data/src/service/async-data-service/README.md @@ -102,7 +102,7 @@ See [create-lazy.md](./create-lazy.md) for complete documentation. ### `AsyncDataService.toObserveState(service)` -Observes every `Observe` member a service's schema describes — recursing through nested organizational groups — in parallel, and emits one combined object with the service's nested shape, typed as `AsyncDataService.State` (observe members become their values; actions and action-only groups are omitted). Nothing is emitted until every member has a value; the member set is re-derived when `service.schema` changes. The service must expose its `schema`. +Observes every `Observe` member a service's schema describes — recursing through nested organizational groups — in parallel, and emits one combined object with the service's nested shape, typed as `AsyncDataService.State` (observe members become their values, `undefined` until resolved; actions and action-only groups are omitted). It emits immediately without waiting for every member, then at most once per microtask as members change; the member set is re-derived when `service.schema` changes. The service must expose its `schema`. ```typescript const unobserve = AsyncDataService.toObserveState(service)((state) => { diff --git a/packages/data/src/service/async-data-service/from-partial-properties.test.ts b/packages/data/src/service/async-data-service/from-partial-properties.test.ts new file mode 100644 index 00000000..de0edd63 --- /dev/null +++ b/packages/data/src/service/async-data-service/from-partial-properties.test.ts @@ -0,0 +1,26 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { describe, expect, it } from "vitest"; +import { Observe } from "../../observe/index.js"; +import { fromPartialProperties } from "./from-partial-properties.js"; + +describe("fromPartialProperties", () => { + it("emits unresolved members as undefined, then batches changes per microtask", async () => { + const [a, setA] = Observe.createState(1); + const [b, setB] = Observe.createState(); + const values: unknown[] = []; + const unobserve = fromPartialProperties({ a, b })((value) => values.push(value)); + expect(values).toEqual([{ a: 1, b: undefined }]); + + setA(2); + setB("x"); + expect(values).toHaveLength(1); + await Promise.resolve(); + expect(values).toEqual([{ a: 1, b: undefined }, { a: 2, b: "x" }]); + + setA(3); + unobserve(); + await Promise.resolve(); + expect(values).toHaveLength(2); + }); +}); diff --git a/packages/data/src/service/async-data-service/from-partial-properties.ts b/packages/data/src/service/async-data-service/from-partial-properties.ts new file mode 100644 index 00000000..2b2004aa --- /dev/null +++ b/packages/data/src/service/async-data-service/from-partial-properties.ts @@ -0,0 +1,43 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Observe } from "../../observe/index.js"; + +/** + * Combines named observe functions into one whose value has the same keys, without + * waiting for all of them: a member that has not yet emitted reads as `undefined` + * (never a valid `Data` value, so it unambiguously means "not resolved yet"). + * + * Emits synchronously once on subscribe with whatever resolved synchronously, then at + * most once per microtask however many members change within it. + */ +export function fromPartialProperties }>( + observeProperties: T, +): Observe<{ readonly [K in keyof T]: (T[K] extends Observe ? U : never) | undefined }> { + type Values = { readonly [K in keyof T]: (T[K] extends Observe ? U : never) | undefined }; + return (observer) => { + const values: Record = {}; + for (const key of Object.keys(observeProperties)) values[key] = undefined; + let initializing = true; + let scheduled = false; + let active = true; + // Every key of `T` is assigned above, and each value is the latest from that key's observe. + const notify = () => observer({ ...values } as Values); + const unobservers = Object.entries(observeProperties).map(([key, observe]) => + observe((value) => { + values[key] = value; + if (initializing || scheduled) return; + scheduled = true; + queueMicrotask(() => { + scheduled = false; + if (active) notify(); + }); + }), + ); + initializing = false; + notify(); + return () => { + active = false; + for (const unobserve of unobservers) unobserve(); + }; + }; +} diff --git a/packages/data/src/service/async-data-service/state.ts b/packages/data/src/service/async-data-service/state.ts index ef7ea9b1..17277475 100644 --- a/packages/data/src/service/async-data-service/state.ts +++ b/packages/data/src/service/async-data-service/state.ts @@ -3,18 +3,19 @@ import { Observe } from "../../observe/index.js"; import { Service } from "../service.js"; -// A member's state value: an `Observe` becomes `T`, a nested organizational group -// becomes its own `State` (dropped when it holds no observe members), and anything -// else (an action) is `never`, which removes the key. +// A member's state value: an `Observe` becomes `T | undefined` (undefined until it +// resolves), a nested organizational group becomes its own `State` (dropped when it +// holds no observe members), and anything else (an action) is `never`, which removes +// the key. type StateValue

= - P extends Observe ? T + P extends Observe ? T | undefined : P extends (...args: any[]) => any ? never : P extends object ? (keyof State

extends never ? never : State

) : never; /** * The current-value snapshot of a service: the same nested shape as `T`, keeping only - * its `Observe` members (converted to `X`) and the groups that contain them. Actions + * its `Observe` members (converted to `X | undefined`, undefined until resolved) and the groups that contain them. Actions * and base-`Service` metadata are omitted. Produced at runtime by `toObserveState`. */ export type State = { diff --git a/packages/data/src/service/async-data-service/to-observe-state.test.ts b/packages/data/src/service/async-data-service/to-observe-state.test.ts index 31d01ea5..ed9247f8 100644 --- a/packages/data/src/service/async-data-service/to-observe-state.test.ts +++ b/packages/data/src/service/async-data-service/to-observe-state.test.ts @@ -51,13 +51,13 @@ interface TestService extends Service { readonly optional?: Observe; } -// Observe members become their values, actions and action-only groups are omitted, -// optional members stay optional. +// Observe members become their values (undefined until resolved), actions and +// action-only groups are omitted, optional members stay optional. type _CheckState = Assert, { - readonly count: number; - readonly foo: { readonly state: { readonly a: string; readonly b: boolean } }; - readonly "dotted.key": number; - readonly optional?: string; + readonly count: number | undefined; + readonly foo: { readonly state: { readonly a: string | undefined; readonly b: boolean | undefined } }; + readonly "dotted.key": number | undefined; + readonly optional?: string | undefined; }>>; function createTestService() { @@ -87,19 +87,21 @@ describe("AsyncDataService.toObserveState", () => { unobserve(); }); - it("re-emits when any member changes and stops after unobserve", () => { + it("re-emits once per microtask when members change and stops after unobserve", async () => { const { service, setCount, setA } = createTestService(); const values: AsyncDataService.State[] = []; const unobserve = AsyncDataService.toObserveState(service)((value) => values.push(value)); setA("y"); setCount(2); - expect(values.map((value) => [value.count, value.foo.state.a])).toEqual([[1, "x"], [1, "y"], [2, "y"]]); + await Promise.resolve(); + expect(values.map((value) => [value.count, value.foo.state.a])).toEqual([[1, "x"], [2, "y"]]); unobserve(); setCount(3); - expect(values).toHaveLength(3); + await Promise.resolve(); + expect(values).toHaveLength(2); }); - it("waits until every member has a value", () => { + it("emits without waiting for unresolved members, which read as undefined", async () => { const [late, setLate] = Observe.createState(); const service = { schema: Observe.fromConstant({ @@ -111,9 +113,10 @@ describe("AsyncDataService.toObserveState", () => { }; const values: unknown[] = []; AsyncDataService.toObserveState(service)((value) => values.push(value)); - expect(values).toEqual([]); + expect(values).toEqual([{ now: 1, late: undefined }]); setLate(2); - expect(values).toEqual([{ now: 1, late: 2 }]); + await Promise.resolve(); + expect(values).toEqual([{ now: 1, late: undefined }, { now: 1, late: 2 }]); }); it("skips members the schema describes but the instance lacks", () => { diff --git a/packages/data/src/service/async-data-service/to-observe-state.ts b/packages/data/src/service/async-data-service/to-observe-state.ts index 4992b50f..42cb3eef 100644 --- a/packages/data/src/service/async-data-service/to-observe-state.ts +++ b/packages/data/src/service/async-data-service/to-observe-state.ts @@ -3,15 +3,17 @@ import { Observe } from "../../observe/index.js"; import { Schema } from "../../schema/index.js"; import { Service } from "../service.js"; +import { fromPartialProperties } from "./from-partial-properties.js"; import { State } from "./state.js"; /** * Observes every `Observe` member of a service — found by recursively walking its * schema, through nested organizational groups — in parallel, and emits them combined * into one object with the service's nested shape (`foo.state.b` is read as - * `state.foo.state.b`). Like `Observe.fromProperties`, nothing is emitted until every - * member has a value, then once per change. When the service's schema changes, the - * member set is re-derived. + * `state.foo.state.b`). It does not wait for every member: one that has not resolved + * yet reads as `undefined`. It emits once on subscribe, then at most once per microtask + * however many members change. When the service's schema changes, the member set is + * re-derived. * * Every observe member the schema describes is included, regardless of its `external` * policy; members the schema describes but the instance lacks (optional) are skipped. @@ -24,7 +26,7 @@ function observeState(service: T, schema: Schema): Observe> = {}; collect(service, schema, [], paths, members); - return Observe.withMap(Observe.fromProperties(members), (values) => { + return Observe.withMap(fromPartialProperties(members), (values) => { const state: Record = {}; paths.forEach((path, index) => { let target = state;