diff --git a/packages/shadow-objects/CHANGELOG.md b/packages/shadow-objects/CHANGELOG.md index b7aee19..b7da7ea 100644 --- a/packages/shadow-objects/CHANGELOG.md +++ b/packages/shadow-objects/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- improve `useProperties()` type inference with key-to-type maps - **API Update:** `on()` and `once()` in `ShadowObjectCreationAPI` now support an implicit event source. - If the first argument is a `string`, `symbol`, or `[]`, the `entity` is automatically used as the event source. - Example: `on('eventName', callback)` is equivalent to `on(entity, 'eventName', callback)`. diff --git a/packages/shadow-objects/docs/02-guides/02-creating-shadow-objects.md b/packages/shadow-objects/docs/02-guides/02-creating-shadow-objects.md index 671bc4a..07270e1 100644 --- a/packages/shadow-objects/docs/02-guides/02-creating-shadow-objects.md +++ b/packages/shadow-objects/docs/02-guides/02-creating-shadow-objects.md @@ -9,13 +9,15 @@ The recommended way to define a Shadow Object is a simple function. This functio ```typescript import { ShadowObjectCreationAPI } from "@spearwolf/shadow-objects"; -export function UserProfileLogic({ - useProperty, - createEffect +export function UserProfileLogic({ + useProperties, + createEffect, }: ShadowObjectCreationAPI) { // 1. Setup Phase: Define your reactive graph here - const userId = useProperty('userId'); + const { userId } = useProperties<{userId: string}>({ + userId: 'userId', + }); createEffect(() => { // 2. Runtime Phase: This runs whenever userId changes diff --git a/packages/shadow-objects/docs/03-api/01-shadow-object-api.md b/packages/shadow-objects/docs/03-api/01-shadow-object-api.md index 780a091..334909f 100644 --- a/packages/shadow-objects/docs/03-api/01-shadow-object-api.md +++ b/packages/shadow-objects/docs/03-api/01-shadow-object-api.md @@ -39,12 +39,16 @@ createEffect(() => { A convenience helper to create multiple property signals at once. -* **Signature:** `useProperties(map: Record): Record any>` +* **Signature:** `useProperties(map: { [K in keyof T]: string }): { [K in keyof T]: () => T[K] | undefined }` * **Returns:** An object where keys match the input map, and values are signal readers. ```typescript -const { x, y } = useProperties({ x: 0, y: 0 }); -// x() and y() are now signals +const { foo, bar } = useProperties<{ foo: number; bar: string }>({ + foo: 'prop.name.foo', + bar: 'prop.bar', +}); +// foo(): number | undefined +// bar(): string | undefined ``` --- diff --git a/packages/shadow-objects/docs/03-api/03-view-components.md b/packages/shadow-objects/docs/03-api/03-view-components.md index 877fa61..b925b00 100644 --- a/packages/shadow-objects/docs/03-api/03-view-components.md +++ b/packages/shadow-objects/docs/03-api/03-view-components.md @@ -118,16 +118,15 @@ class GameEntity { }); // Sync position to Shadow World - this.viewComponent.setProperties({ - x: this.x, - y: this.y - }); + this.viewComponent.setProperty('x', this.x); + this.viewComponent.setProperty('y', this.y); } update() { // Send updates every frame (or optimally, only on change) if (this.moved) { - this.viewComponent.setProperties({ x: this.x, y: this.y }); + this.viewComponent.setProperty('x', this.x); + this.viewComponent.setProperty('y', this.y); } } diff --git a/packages/shadow-objects/docs/04-patterns/best-practices.md b/packages/shadow-objects/docs/04-patterns/best-practices.md index a3f0143..7b93876 100644 --- a/packages/shadow-objects/docs/04-patterns/best-practices.md +++ b/packages/shadow-objects/docs/04-patterns/best-practices.md @@ -127,10 +127,10 @@ const myMeshResource = createResource( If you need multiple properties, avoid calling `useProperty` multiple times. Use `useProperties` to get a structured object of signals. ```typescript -const { x, y, visible } = useProperties({ +const { x, y, visible } = useProperties<{ x: number; y: number; visible: boolean }>({ x: "position-x", y: "position-y", - visible: "is-visible" + visible: "is-visible", }); ``` diff --git a/packages/shadow-objects/src/in-the-dark/Kernel.spec.ts b/packages/shadow-objects/src/in-the-dark/Kernel.spec.ts index 7dd9846..02ad0e3 100644 --- a/packages/shadow-objects/src/in-the-dark/Kernel.spec.ts +++ b/packages/shadow-objects/src/in-the-dark/Kernel.spec.ts @@ -338,6 +338,42 @@ describe('Kernel', () => { kernel.destroy(); }); + + it('should support typed property maps', () => { + const registry = new Registry(); + const kernel = new Kernel(registry); + + let capturedProps: + | { + foo: SignalReader; + bar: SignalReader; + } + | undefined; + + @ShadowObject({registry, token: 'testTypedUseProperties'}) + class TestTypedUseProperties { + constructor({useProperties}: ShadowObjectCreationAPI) { + const props = useProperties<{foo: number; bar: string}>({ + foo: 'propA', + bar: 'propB', + }); + capturedProps = props; + } + } + expect(TestTypedUseProperties).toBeDefined(); + + const uuid = generateUUID(); + kernel.createEntity(uuid, 'testTypedUseProperties', undefined, 0, [ + ['propA', 123], + ['propB', 'valueB'], + ]); + + expect(capturedProps).toBeDefined(); + expect(value(capturedProps!.foo)).toBe(123); + expect(value(capturedProps!.bar)).toBe('valueB'); + + kernel.destroy(); + }); }); describe('provideContext and useContext', () => { diff --git a/packages/shadow-objects/src/in-the-dark/Kernel.ts b/packages/shadow-objects/src/in-the-dark/Kernel.ts index e9abe85..1511671 100644 --- a/packages/shadow-objects/src/in-the-dark/Kernel.ts +++ b/packages/shadow-objects/src/in-the-dark/Kernel.ts @@ -360,12 +360,12 @@ export class Kernel { const contextProviders = new Map>(); const contextRootProviders = new Map>(); - const propertyReaders = new Map>(); + const propertyReaders = new Map>(); - const getUseProperty = ( + const getUseProperty = ( name: string, options?: SignalValueOptions | CompareFunc, - ): SignalReader => { + ): SignalReader> => { if (!usePropertyOptionsDeprecatedShown && options != null && typeof options === 'function') { console.warn( '[shadow-objects] Deprecation Warning: The "isEqual" option of "useProperty()" is now passed as {compare} argument. Please update your code accordingly.', @@ -375,11 +375,11 @@ export class Kernel { const opts = typeof options === 'function' ? {compare: options} : options; - let propReader = propertyReaders.get(name); + let propReader = propertyReaders.get(name) as SignalReader> | undefined; if (propReader === undefined) { - propReader = createSignal(undefined, opts).get; - propertyReaders.set(name, propReader); + propReader = createSignal>(undefined, opts).get; + propertyReaders.set(name, propReader as SignalReader); const con = link(entry.entity.getPropertyReader(name), propReader); unsubscribeSecondary.add(con.destroy.bind(con)); } @@ -523,8 +523,10 @@ export class Kernel { useProperty: getUseProperty, - useProperties(props: Record): Record> { - const result = {} as Record>; + useProperties = Record>( + props: {[K in keyof T]: string}, + ): {[K in keyof T]: SignalReader>} { + const result = {} as {[K in keyof T]: SignalReader>}; for (const key in props) { if (Object.hasOwn(props, key)) { result[key] = getUseProperty(props[key]); diff --git a/packages/shadow-objects/src/types.ts b/packages/shadow-objects/src/types.ts index 09eea90..ba487ff 100644 --- a/packages/shadow-objects/src/types.ts +++ b/packages/shadow-objects/src/types.ts @@ -132,7 +132,9 @@ export interface ShadowObjectCreationAPI { useProperty(name: string, options?: SignalValueOptions | CompareFunc): SignalReader>; - useProperties(props: Record): Record>; + useProperties = Record>( + props: {[K in keyof T]: string}, + ): {[K in keyof T]: SignalReader>}; createResource(factory: () => T | undefined, cleanup?: (resource: NonNullable) => unknown): Signal>;