Skip to content

refactor(presence): Pass arguments as an object to StateFactory.latest/latestMap #24414

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 25 commits into from
Apr 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/eleven-groups-open.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@fluidframework/presence": minor
"__section": other
---
StateFactory.latest/latestMap now takes an object as its only argument

The `StateFactory.latest` and `StateFactory.latestMap` functions now take a single object argument. To convert existing
code, pass any initial data in the `local` argument, and broadcast settings in the `settings` argument. For example:

Before:

```ts
const statesWorkspace = presence.states.getWorkspace("name:workspace", {
cursor: StateFactory.latest(
{ x: 0, y: 0 },
{ allowableUpdateLatencyMs: 100 }),
});
```

After:

```ts
const statesWorkspace = presence.states.getWorkspace("name:workspace", {
cursor: StateFactory.latest({
local: { x: 0, y: 0 },
settings: { allowableUpdateLatencyMs: 100 },
}),
});
```
2 changes: 1 addition & 1 deletion examples/apps/ai-collab/src/app/presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface User {
}

const statesSchema = {
onlineUsers: StateFactory.latest({ photo: "" } satisfies User),
onlineUsers: StateFactory.latest({ local: { photo: "" } satisfies User }),
} satisfies StatesWorkspaceSchema;

export type UserPresence = StatesWorkspace<typeof statesSchema>;
Expand Down
4 changes: 3 additions & 1 deletion examples/apps/presence-tracker/src/FocusTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ export class FocusTracker extends TypedEventEmitter<IFocusTrackerEvents> {
// window.
statesWorkspace.add(
"focus",
StateFactory.latest<IFocusState>({ hasFocus: window.document.hasFocus() }),
StateFactory.latest<IFocusState>({
local: { hasFocus: window.document.hasFocus() },
}),
);

// Save a reference to the focus state for easy access within the FocusTracker.
Expand Down
5 changes: 4 additions & 1 deletion examples/apps/presence-tracker/src/MouseTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ export class MouseTracker extends TypedEventEmitter<IMouseTrackerEvents> {
super();

// Create a Latest state object to track the mouse position.
statesWorkspace.add("cursor", StateFactory.latest<IMousePosition>({ x: 0, y: 0 }));
statesWorkspace.add(
"cursor",
StateFactory.latest<IMousePosition>({ local: { x: 0, y: 0 } }),
);

// Save a reference to the cursor state for easy access within the MouseTracker.
this.cursor = statesWorkspace.props.cursor;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export interface DiceValues {
*/
const statesSchema = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
lastRoll: StateFactory.latest({} as DiceValues),
lastRoll: StateFactory.latest({ local: {} as DiceValues }),
lastDiceRolls: StateFactory.latestMap<{ value: DieValue }, `die${number}`>(),
} satisfies StatesWorkspaceSchema;

Expand Down
20 changes: 16 additions & 4 deletions packages/framework/presence/api-report/presence.alpha.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,13 @@ export namespace InternalUtilityTypes {
}

// @alpha
export function latest<T extends object | null, Key extends string = string>(initialValue: JsonSerializable<T> & JsonDeserialized<T> & (object | null), controls?: BroadcastControlSettings): InternalTypes.ManagerFactory<Key, InternalTypes.ValueRequiredState<T>, LatestRaw<T>>;
export function latest<T extends object | null, Key extends string = string>(args: LatestArguments<T>): InternalTypes.ManagerFactory<Key, InternalTypes.ValueRequiredState<T>, LatestRaw<T>>;

// @alpha
export interface LatestArguments<T extends object | null> {
local: JsonSerializable<T> & JsonDeserialized<T> & (object | null);
settings?: BroadcastControlSettings | undefined;
}

// @alpha @sealed
export interface LatestClientData<T> extends LatestData<T> {
Expand All @@ -153,9 +159,15 @@ export interface LatestData<T> {
}

// @alpha
export function latestMap<T, Keys extends string | number = string | number, RegistrationKey extends string = string>(initialValues?: {
[K in Keys]: JsonSerializable<T> & JsonDeserialized<T>;
}, controls?: BroadcastControlSettings): InternalTypes.ManagerFactory<RegistrationKey, InternalTypes.MapValueState<T, Keys>, LatestMapRaw<T, Keys>>;
export function latestMap<T, Keys extends string | number = string | number, RegistrationKey extends string = string>(args?: LatestMapArguments<T, Keys>): InternalTypes.ManagerFactory<RegistrationKey, InternalTypes.MapValueState<T, Keys>, LatestMapRaw<T, Keys>>;

// @alpha
export interface LatestMapArguments<T, Keys extends string | number = string | number> {
local?: {
[K in Keys]: JsonSerializable<T> & JsonDeserialized<T>;
};
settings?: BroadcastControlSettings | undefined;
}

// @alpha @sealed
export interface LatestMapClientData<T, Keys extends string | number, SpecificAttendeeId extends AttendeeId = AttendeeId> {
Expand Down
2 changes: 2 additions & 0 deletions packages/framework/presence/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export {

export type {
latestMap,
LatestMapArguments,
LatestMapRaw,
LatestMapClientData,
LatestMapRawEvents,
Expand All @@ -56,6 +57,7 @@ export type {
} from "./latestMapValueManager.js";
export type {
latest,
LatestArguments,
LatestRaw,
LatestRawEvents,
} from "./latestValueManager.js";
Expand Down
37 changes: 30 additions & 7 deletions packages/framework/presence/src/latestMapValueManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,25 @@ class LatestMapRawValueManagerImpl<
}
}

/**
* Arguments that are passed to the {@link StateFactory.latestMap} function.
*
* @alpha
*/
export interface LatestMapArguments<T, Keys extends string | number = string | number> {
/**
* The initial value of the local state.
*/
local?: {
[K in Keys]: JsonSerializable<T> & JsonDeserialized<T>;
};

/**
* See {@link BroadcastControlSettings}.
*/
settings?: BroadcastControlSettings | undefined;
}

/**
* Factory for creating a {@link LatestMapRaw} State object.
*
Expand All @@ -499,15 +518,15 @@ export function latestMap<
Keys extends string | number = string | number,
RegistrationKey extends string = string,
>(
initialValues?: {
[K in Keys]: JsonSerializable<T> & JsonDeserialized<T>;
},
controls?: BroadcastControlSettings,
args?: LatestMapArguments<T, Keys>,
): InternalTypes.ManagerFactory<
RegistrationKey,
InternalTypes.MapValueState<T, Keys>,
LatestMapRaw<T, Keys>
> {
const settings = args?.settings;
const initialValues = args?.local;

const timestamp = Date.now();
const value: InternalTypes.MapValueState<
T,
Expand All @@ -517,7 +536,11 @@ export function latestMap<
// LatestMapRaw takes ownership of values within initialValues.
if (initialValues !== undefined) {
for (const key of objectKeys(initialValues)) {
value.items[key] = { rev: 0, timestamp, value: initialValues[key] };
value.items[key] = {
rev: 0,
timestamp,
value: initialValues[key],
};
}
}
const factory = (
Expand All @@ -530,7 +553,7 @@ export function latestMap<
initialData: { value: typeof value; allowableUpdateLatencyMs: number | undefined };
manager: InternalTypes.StateValue<LatestMapRaw<T, Keys>>;
} => ({
initialData: { value, allowableUpdateLatencyMs: controls?.allowableUpdateLatencyMs },
initialData: { value, allowableUpdateLatencyMs: settings?.allowableUpdateLatencyMs },
manager: brandIVM<
LatestMapRawValueManagerImpl<T, RegistrationKey, Keys>,
T,
Expand All @@ -540,7 +563,7 @@ export function latestMap<
key,
datastoreFromHandle(datastoreHandle),
value,
controls,
settings,
),
),
});
Expand Down
32 changes: 25 additions & 7 deletions packages/framework/presence/src/latestValueManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,22 +181,40 @@ class LatestValueManagerImpl<T, Key extends string>
}
}

/**
* Arguments that are passed to the {@link StateFactory.latest} function.
*
* @alpha
*/
export interface LatestArguments<T extends object | null> {
/**
* The initial value of the local state.
*/
// eslint-disable-next-line @rushstack/no-new-null
local: JsonSerializable<T> & JsonDeserialized<T> & (object | null);

/**
* See {@link BroadcastControlSettings}.
*/
settings?: BroadcastControlSettings | undefined;
}

/**
* Factory for creating a {@link LatestRaw} State object.
*
* @alpha
*/
export function latest<T extends object | null, Key extends string = string>(
// eslint-disable-next-line @rushstack/no-new-null
initialValue: JsonSerializable<T> & JsonDeserialized<T> & (object | null),
controls?: BroadcastControlSettings,
args: LatestArguments<T>,
): InternalTypes.ManagerFactory<Key, InternalTypes.ValueRequiredState<T>, LatestRaw<T>> {
// Latest takes ownership of initialValue but makes a shallow
const { local, settings } = args;

// Latest takes ownership of the initial local value but makes a shallow
// copy for basic protection.
const value: InternalTypes.ValueRequiredState<T> = {
rev: 0,
timestamp: Date.now(),
value: initialValue === null ? initialValue : shallowCloneObject(initialValue),
value: local === null ? local : shallowCloneObject(local),
};
const factory = (
key: Key,
Expand All @@ -208,9 +226,9 @@ export function latest<T extends object | null, Key extends string = string>(
initialData: { value: typeof value; allowableUpdateLatencyMs: number | undefined };
manager: InternalTypes.StateValue<LatestRaw<T>>;
} => ({
initialData: { value, allowableUpdateLatencyMs: controls?.allowableUpdateLatencyMs },
initialData: { value, allowableUpdateLatencyMs: settings?.allowableUpdateLatencyMs },
manager: brandIVM<LatestValueManagerImpl<T, Key>, T, InternalTypes.ValueRequiredState<T>>(
new LatestValueManagerImpl(key, datastoreFromHandle(datastoreHandle), value, controls),
new LatestValueManagerImpl(key, datastoreFromHandle(datastoreHandle), value, settings),
),
});
return Object.assign(factory, { instanceBase: LatestValueManagerImpl });
Expand Down
53 changes: 42 additions & 11 deletions packages/framework/presence/src/test/batching.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,10 @@ describe("Presence", () => {
// Configure a state workspace
// SIGNAL #1 - intial data is sent immediately
const stateWorkspace = presence.states.getWorkspace("name:testStateWorkspace", {
count: StateFactory.latest({ num: 0 }, { allowableUpdateLatencyMs: 0 }),
count: StateFactory.latest({
local: { num: 0 },
settings: { allowableUpdateLatencyMs: 0 },
}),
});

const { count } = stateWorkspace.props;
Expand Down Expand Up @@ -193,7 +196,9 @@ describe("Presence", () => {

// Configure a state workspace
presence.states.getWorkspace("name:testStateWorkspace", {
count: StateFactory.latest({ num: 0 } /* default allowableUpdateLatencyMs = 60 */),
count: StateFactory.latest({
local: { num: 0 } /* default allowableUpdateLatencyMs = 60 */,
}),
}); // will be queued; deadline is now 1070

// SIGNAL #1
Expand Down Expand Up @@ -267,7 +272,9 @@ describe("Presence", () => {

// Configure a state workspace
const stateWorkspace = presence.states.getWorkspace("name:testStateWorkspace", {
count: StateFactory.latest({ num: 0 } /* default allowableUpdateLatencyMs = 60 */),
count: StateFactory.latest({
local: { num: 0 } /* default allowableUpdateLatencyMs = 60 */,
}),
}); // will be queued; deadline is now 1070

const { count } = stateWorkspace.props;
Expand Down Expand Up @@ -369,7 +376,10 @@ describe("Presence", () => {

// Configure a state workspace
const stateWorkspace = presence.states.getWorkspace("name:testStateWorkspace", {
count: StateFactory.latest({ num: 0 }, { allowableUpdateLatencyMs: 100 }),
count: StateFactory.latest({
local: { num: 0 },
settings: { allowableUpdateLatencyMs: 100 },
}),
});

const { count } = stateWorkspace.props;
Expand Down Expand Up @@ -488,8 +498,14 @@ describe("Presence", () => {
// SIGNAL #1 - this signal is not queued because it contains a State object with a latency of 0,
// so the initial data will be sent immediately.
const stateWorkspace = presence.states.getWorkspace("name:testStateWorkspace", {
count: StateFactory.latest({ num: 0 }, { allowableUpdateLatencyMs: 100 }),
immediateUpdate: StateFactory.latest({ num: 0 }, { allowableUpdateLatencyMs: 0 }),
count: StateFactory.latest({
local: { num: 0 },
settings: { allowableUpdateLatencyMs: 100 },
}),
immediateUpdate: StateFactory.latest({
local: { num: 0 },
settings: { allowableUpdateLatencyMs: 0 },
}),
});

const { count, immediateUpdate } = stateWorkspace.props;
Expand Down Expand Up @@ -575,8 +591,14 @@ describe("Presence", () => {

// Configure a state workspace
const stateWorkspace = presence.states.getWorkspace("name:testStateWorkspace", {
count: StateFactory.latest({ num: 0 }, { allowableUpdateLatencyMs: 100 }),
note: StateFactory.latest({ message: "" }, { allowableUpdateLatencyMs: 50 }),
count: StateFactory.latest({
local: { num: 0 },
settings: { allowableUpdateLatencyMs: 100 },
}),
note: StateFactory.latest({
local: { message: "" },
settings: { allowableUpdateLatencyMs: 50 },
}),
}); // will be queued, deadline is set to 1060

const { count, note } = stateWorkspace.props;
Expand Down Expand Up @@ -647,11 +669,17 @@ describe("Presence", () => {

// Configure two state workspaces
const stateWorkspace = presence.states.getWorkspace("name:testStateWorkspace", {
count: StateFactory.latest({ num: 0 }, { allowableUpdateLatencyMs: 100 }),
count: StateFactory.latest({
local: { num: 0 },
settings: { allowableUpdateLatencyMs: 100 },
}),
}); // will be queued, deadline is 1110

const stateWorkspace2 = presence.states.getWorkspace("name:testStateWorkspace2", {
note: StateFactory.latest({ message: "" }, { allowableUpdateLatencyMs: 60 }),
note: StateFactory.latest({
local: { message: "" },
settings: { allowableUpdateLatencyMs: 60 },
}),
}); // will be queued, deadline is 1070

const { count } = stateWorkspace.props;
Expand Down Expand Up @@ -840,7 +868,10 @@ describe("Presence", () => {

// Configure a state workspace
const stateWorkspace = presence.states.getWorkspace("name:testStateWorkspace", {
count: StateFactory.latest({ num: 0 }, { allowableUpdateLatencyMs: 100 }),
count: StateFactory.latest({
local: { num: 0 },
settings: { allowableUpdateLatencyMs: 100 },
}),
}); // will be queued, deadline is 1110

// eslint-disable-next-line @typescript-eslint/ban-types
Expand Down
12 changes: 8 additions & 4 deletions packages/framework/presence/src/test/eventing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,10 @@ describe("Presence", () => {
notifications,
}: { notifications?: true } = {}): void {
const states = presence.states.getWorkspace("name:testWorkspace", {
latest: StateFactory.latest({ x: 0, y: 0, z: 0 }),
latestMap: StateFactory.latestMap({ key1: { a: 0, b: 0 }, key2: { c: 0, d: 0 } }),
latest: StateFactory.latest({ local: { x: 0, y: 0, z: 0 } }),
latestMap: StateFactory.latestMap({
local: { key1: { a: 0, b: 0 }, key2: { c: 0, d: 0 } },
}),
});
latest = states.props.latest;
latestMap = states.props.latestMap;
Expand All @@ -265,10 +267,12 @@ describe("Presence", () => {

function setupMultipleStatesWorkspaces(): void {
const latestsStates = presence.states.getWorkspace("name:testWorkspace1", {
latest: StateFactory.latest({ x: 0, y: 0, z: 0 }),
latest: StateFactory.latest({ local: { x: 0, y: 0, z: 0 } }),
});
const latesetMapStates = presence.states.getWorkspace("name:testWorkspace2", {
latestMap: StateFactory.latestMap({ key1: { a: 0, b: 0 }, key2: { c: 0, d: 0 } }),
latestMap: StateFactory.latestMap({
local: { key1: { a: 0, b: 0 }, key2: { c: 0, d: 0 } },
}),
});
latest = latestsStates.props.latest;
latestMap = latesetMapStates.props.latestMap;
Expand Down
Loading
Loading