diff --git a/.changeset/easy-bats-type.md b/.changeset/easy-bats-type.md new file mode 100644 index 000000000000..afbcb8152f38 --- /dev/null +++ b/.changeset/easy-bats-type.md @@ -0,0 +1,8 @@ +--- +"@fluidframework/presence": minor +"__section": feature +--- +Latest and LatestMap support more types + +- `Latest` (`StateFactory.latest`) permits `null` so that nullable types may be used. +- `LatestMap` (`StateFactory.latestMap`) permits `boolean`, `number`, `string`, and `null`. diff --git a/packages/framework/presence/api-report/presence.alpha.api.md b/packages/framework/presence/api-report/presence.alpha.api.md index 508fcdbe60a3..8a1f402ef369 100644 --- a/packages/framework/presence/api-report/presence.alpha.api.md +++ b/packages/framework/presence/api-report/presence.alpha.api.md @@ -148,7 +148,7 @@ export interface Latest { } // @alpha -export function latest(initialValue: JsonSerializable & JsonDeserialized & object, controls?: BroadcastControlSettings): InternalTypes.ManagerFactory, Latest>; +export function latest(initialValue: JsonSerializable & JsonDeserialized & (object | null), controls?: BroadcastControlSettings): InternalTypes.ManagerFactory, Latest>; // @alpha @sealed export interface LatestClientData extends LatestData { @@ -186,7 +186,7 @@ export interface LatestMap { } // @alpha -export function latestMap(initialValues?: { +export function latestMap(initialValues?: { [K in Keys]: JsonSerializable & JsonDeserialized; }, controls?: BroadcastControlSettings): InternalTypes.ManagerFactory, LatestMap>; diff --git a/packages/framework/presence/src/latestMapValueManager.ts b/packages/framework/presence/src/latestMapValueManager.ts index 951201bff386..0789a87c831e 100644 --- a/packages/framework/presence/src/latestMapValueManager.ts +++ b/packages/framework/presence/src/latestMapValueManager.ts @@ -495,7 +495,7 @@ class LatestMapValueManagerImpl< * @alpha */ export function latestMap< - T extends object, + T, Keys extends string | number = string | number, RegistrationKey extends string = string, >( diff --git a/packages/framework/presence/src/latestValueManager.ts b/packages/framework/presence/src/latestValueManager.ts index 0c1c2456eb1d..bfd4d7a584db 100644 --- a/packages/framework/presence/src/latestValueManager.ts +++ b/packages/framework/presence/src/latestValueManager.ts @@ -186,8 +186,9 @@ class LatestValueManagerImpl * * @alpha */ -export function latest( - initialValue: JsonSerializable & JsonDeserialized & object, +export function latest( + // eslint-disable-next-line @rushstack/no-new-null + initialValue: JsonSerializable & JsonDeserialized & (object | null), controls?: BroadcastControlSettings, ): InternalTypes.ManagerFactory, Latest> { // Latest takes ownership of initialValue but makes a shallow @@ -195,7 +196,7 @@ export function latest( const value: InternalTypes.ValueRequiredState = { rev: 0, timestamp: Date.now(), - value: shallowCloneObject(initialValue), + value: initialValue === null ? initialValue : shallowCloneObject(initialValue), }; const factory = ( key: Key, diff --git a/packages/framework/presence/src/test/latestMapValueManager.spec.ts b/packages/framework/presence/src/test/latestMapValueManager.spec.ts index b7dc5923a359..99bcc4667804 100644 --- a/packages/framework/presence/src/test/latestMapValueManager.spec.ts +++ b/packages/framework/presence/src/test/latestMapValueManager.spec.ts @@ -138,6 +138,9 @@ export function checkCompiles(): void { console.log(key, value); } + // ---------------------------------- + // pointers data + interface PointerData { x: number; y: number; @@ -184,4 +187,32 @@ export function checkCompiles(): void { pointers.events.on("remoteUpdated", ({ attendee, items }) => { for (const [key, { value }] of items.entries()) logClientValue({ attendee, key, value }); }); + + // ---------------------------------- + // primitive and null value support + + workspace.add( + "primitiveMap", + StateFactory.latestMap({ + // eslint-disable-next-line unicorn/no-null + null: null, + string: "string", + number: 0, + boolean: true, + }), + ); + + const localPrimitiveMap = workspace.props.primitiveMap.local; + + // map value types are not matched to specific key + localPrimitiveMap.set("string", 1); + localPrimitiveMap.set("number", false); + // eslint-disable-next-line unicorn/no-null + localPrimitiveMap.set("boolean", null); + localPrimitiveMap.set("null", "null"); + + // @ts-expect-error with inferred keys only those named in init are accessible + localPrimitiveMap.set("key3", "value"); + // @ts-expect-error value of type value is not assignable + localPrimitiveMap.set("null", { value: "value" }); } diff --git a/packages/framework/presence/src/test/latestValueManager.spec.ts b/packages/framework/presence/src/test/latestValueManager.spec.ts index 8ef15bbe117d..670cc0d2bfe3 100644 --- a/packages/framework/presence/src/test/latestValueManager.spec.ts +++ b/packages/framework/presence/src/test/latestValueManager.spec.ts @@ -19,6 +19,8 @@ import { StateFactory } from "@fluidframework/presence/alpha"; const testWorkspaceName = "name:testWorkspaceA"; +/* eslint-disable unicorn/no-null */ + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type function createLatestManager( presence: Presence, @@ -72,6 +74,13 @@ describe("Presence", () => { assert.deepStrictEqual(states.props.arr.local, [1, 2, 3]); }); + it("can set and get null as initial value", () => { + const states = presence.states.getWorkspace(testWorkspaceName, { + arr: StateFactory.latest(null), + }); + assert.deepStrictEqual(states.props.arr.local, null); + }); + it(".presence provides Presence it was created under", () => { const states = presence.states.getWorkspace(testWorkspaceName, { camera: StateFactory.latest({ x: 0, y: 0, z: 0 }), @@ -79,6 +88,17 @@ describe("Presence", () => { assert.strictEqual(states.props.camera.presence, presence); }); + + it("can set and get null as modified local value", () => { + // Setup + const states = presence.states.getWorkspace(testWorkspaceName, { + nullable: StateFactory.latest<{ x: number; y: number } | null>({ x: 0, y: 0 }), + }); + + // Act and Verify + states.props.nullable.local = null; + assert.deepStrictEqual(states.props.nullable.local, null); + }); }); addControlsTests(createLatestManager); @@ -115,6 +135,7 @@ export function checkCompiles(): void { const statesWorkspace = presence.states.getWorkspace("name:testStatesWorkspaceWithLatest", { cursor: StateFactory.latest({ x: 0, y: 0 }), camera: StateFactory.latest({ x: 0, y: 0, z: 0 }), + nullablePoint: StateFactory.latest(null), }); // Workaround ts(2775): Assertions require every name in the call target to be declared with an explicit type annotation. const workspace: typeof statesWorkspace = statesWorkspace; @@ -141,6 +162,9 @@ export function checkCompiles(): void { // Update our cursor position cursor.local = { x: 1, y: 2 }; + // Set nullable point to non-null value + props.nullablePoint.local = { x: 10, y: -2 }; + // Listen to others cursor updates const cursorUpdatedOff = cursor.events.on("remoteUpdated", ({ attendee, value }) => console.log(`attendee ${attendee.attendeeId}'s cursor is now at (${value.x},${value.y})`), @@ -156,3 +180,5 @@ export function checkCompiles(): void { logClientValue({ attendee, value }); } } + +/* eslint-enable unicorn/no-null */