diff --git a/experimental/framework/independent-state/api-report/independent-state.api.md b/experimental/framework/independent-state/api-report/independent-state.api.md index 22f0a064ddcd..773aee6567cb 100644 --- a/experimental/framework/independent-state/api-report/independent-state.api.md +++ b/experimental/framework/independent-state/api-report/independent-state.api.md @@ -8,8 +8,6 @@ import type { AliasResult } from '@fluidframework/runtime-definitions/internal'; import { FluidDataStoreRuntime } from '@fluidframework/datastore/internal'; import type { IContainerRuntime } from '@fluidframework/container-runtime-definitions/internal'; import type { IContainerRuntimeBase } from '@fluidframework/runtime-definitions/internal'; -import type { IEvent } from '@fluidframework/core-interfaces'; -import type { IEventProvider } from '@fluidframework/core-interfaces'; import type { IFluidDataStoreRuntime } from '@fluidframework/datastore-definitions'; import type { NamedFluidDataStoreRegistryEntry } from '@fluidframework/runtime-definitions/internal'; @@ -19,6 +17,11 @@ export type ClientId = string; // @internal export function createIndependentMap(runtime: IFluidEphemeralDataStoreRuntime, initialContent: TSchema): IndependentMap; +// @beta +export type Events = { + [P in (string | symbol) & keyof E as IsEvent extends true ? P : never]: E[P]; +}; + // @beta type FlattenIntersection = T extends Record ? { [K in keyof T]: T[K]; @@ -115,9 +118,17 @@ type IsEnumLike = T extends readonly (infer _)[] ? false : T e [K in keyof T]: T[K] extends never ? true : never; }[keyof T] ? false : true : false; +// @beta +export type IsEvent = Event extends (...args: any[]) => any ? true : false; + // @beta type IsExactlyObject = object extends Required ? false extends T ? false : true : false; +// @beta +export interface ISubscribable> { + on>(eventName: K, listener: E[K]): () => void; +} + // @beta export type JsonDeserialized = boolean extends (T extends never ? true : false) ? JsonTypeWith : unknown extends T ? JsonTypeWith : T extends null | boolean | number | string | TReplaced ? T : Extract extends never ? T extends object ? T extends readonly (infer _)[] ? { [K in keyof T]: JsonForArrayItem>; @@ -182,18 +193,18 @@ export interface LatestMapValueManager(clientId: SpecificClientId): LatestMapValueClientData; clientValues(): IterableIterator>; - readonly events: IEventProvider>; + readonly events: ISubscribable>; readonly local: ValueMap; } // @beta (undocumented) -export interface LatestMapValueManagerEvents extends IEvent { +export interface LatestMapValueManagerEvents { // @eventProperty - (event: "updated", listener: (updates: LatestMapValueClientData) => void): void; + itemRemoved: (removedItem: LatestMapItemRemovedClientData) => void; // @eventProperty - (event: "itemUpdated", listener: (updatedItem: LatestMapItemValueClientData) => void): void; + itemUpdated: (updatedItem: LatestMapItemValueClientData) => void; // @eventProperty - (event: "itemRemoved", listener: (removedItem: LatestMapItemRemovedClientData) => void): void; + updated: (updates: LatestMapValueClientData) => void; } // @beta @@ -215,15 +226,15 @@ export interface LatestValueManager { clients(): ClientId[]; clientValue(clientId: ClientId): LatestValueData; clientValues(): IterableIterator>; - readonly events: IEventProvider>; + readonly events: ISubscribable>; get local(): FullyReadonly>; set local(value: JsonEncodable & JsonDeserialized); } // @beta (undocumented) -export interface LatestValueManagerEvents extends IEvent { +export interface LatestValueManagerEvents { // @eventProperty - (event: "updated", listener: (update: LatestValueClientData) => void): void; + updated: (update: LatestValueClientData) => void; } // @beta diff --git a/experimental/framework/independent-state/package.json b/experimental/framework/independent-state/package.json index 491fdb485fc5..fd634273a325 100644 --- a/experimental/framework/independent-state/package.json +++ b/experimental/framework/independent-state/package.json @@ -74,7 +74,6 @@ "tsc": "fluid-tsc commonjs --project ./tsconfig.cjs.json && copyfiles -f ../../../common/build/build-common/src/cjs/package.json ./dist" }, "dependencies": { - "@fluid-internal/client-utils": "workspace:~", "@fluidframework/container-runtime-definitions": "workspace:~", "@fluidframework/core-interfaces": "workspace:~", "@fluidframework/core-utils": "workspace:~", diff --git a/experimental/framework/independent-state/src/events.ts b/experimental/framework/independent-state/src/events.ts new file mode 100644 index 000000000000..391ab6d560b7 --- /dev/null +++ b/experimental/framework/independent-state/src/events.ts @@ -0,0 +1,325 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +/** + * This file is a clone of the `events.ts` file from the `@fluidframework/tree` package. + * `@public` APIs have been changed to `@beta` and some lint defects from more strict rules + * have been fixed or suppressed. + */ + +import type { IEvent } from "@fluidframework/core-interfaces"; +import { assert } from "@fluidframework/core-utils/internal"; + +function fail(message: string): never { + throw new Error(message); +} + +/** + * Retrieve a value from a map with the given key, or create a new entry if the key is not in the map. + * @param map - The map to query/update + * @param key - The key to lookup in the map + * @param defaultValue - a function which returns a default value. This is called and used to set an initial value for the given key in the map if none exists + * @returns either the existing value for the given key, or the newly-created value (the result of `defaultValue`) + */ +function getOrCreate(map: Map, key: K, defaultValue: (key: K) => V): V { + let value = map.get(key); + if (value === undefined) { + value = defaultValue(key); + map.set(key, value); + } + return value; +} + +/** + * Convert a union of types to an intersection of those types. Useful for `TransformEvents`. + */ +export type UnionToIntersection = (T extends any ? (k: T) => unknown : never) extends ( + k: infer U, +) => unknown + ? U + : never; + +/** + * `true` iff the given type is an acceptable shape for an event + * @beta + */ +export type IsEvent = Event extends (...args: any[]) => any ? true : false; + +/** + * Used to specify the kinds of events emitted by an {@link ISubscribable}. + * + * @remarks + * + * Any object type is a valid {@link Events}, but only the event-like properties of that + * type will be included. + * + * @example + * + * ```typescript + * interface MyEvents { + * load: (user: string, data: IUserData) => void; + * error: (errorCode: number) => void; + * } + * ``` + * + * @beta + */ +export type Events = { + [P in (string | symbol) & keyof E as IsEvent extends true ? P : never]: E[P]; +}; + +/** + * Converts an `Events` type (i.e. the event registry for an {@link ISubscribable}) into a type consumable + * by an IEventProvider from `@fluidframework/core-interfaces`. + * @param E - the `Events` type to transform + * @param Target - an optional `IEvent` type that will be merged into the result along with the transformed `E` + * + * @example + * + * ```typescript + * interface MyEvents { + * load: (user: string, data: IUserData) => void; + * error: (errorCode: number) => void; + * } + * + * class MySharedObject extends SharedObject> { + * // ... + * } + * ``` + */ +export type TransformEvents, Target extends IEvent = IEvent> = { + [P in keyof Events]: (event: P, listener: E[P]) => void; +} extends Record + ? UnionToIntersection & Target + : never; + +/** + * An object which allows the registration of listeners so that subscribers can be notified when an event happens. + * + * `EventEmitter` can be used as a base class to implement this via extension. + * @param E - All the events that this emitter supports + * @example + * ```ts + * type MyEventEmitter = IEventEmitter<{ + * load: (user: string, data: IUserData) => void; + * error: (errorCode: number) => void; + * }> + * ``` + * @privateRemarks + * {@link createEmitter} can help implement this interface via delegation. + * + * @beta + */ +export interface ISubscribable> { + /** + * Register an event listener. + * @param eventName - the name of the event + * @param listener - the handler to run when the event is fired by the emitter + * @returns a function which will deregister the listener when run. This function has undefined behavior + * if called more than once. + */ + on>(eventName: K, listener: E[K]): () => void; +} + +/** + * Interface for an event emitter that can emit typed events to subscribed listeners. + * @internal + */ +export interface IEmitter> { + /** + * Emits an event with the specified name and arguments, notifying all subscribers by calling their registered listener functions. + * @param eventName - the name of the event to fire + * @param args - the arguments passed to the event listener functions + */ + emit>(eventName: K, ...args: Parameters): void; + + /** + * Emits an event with the specified name and arguments, notifying all subscribers by calling their registered listener functions. + * It also collects the return values of all listeners into an array. + * + * Warning: This method should be used with caution. It deviates from the standard event-based integration pattern as creates substantial coupling between the emitter and its listeners. + * For the majority of use-cases it is recommended to use the standard {@link IEmitter.emit} functionality. + * @param eventName - the name of the event to fire + * @param args - the arguments passed to the event listener functions + * @returns An array of the return values of each listener, preserving the order listeners were called. + */ + emitAndCollect>( + eventName: K, + ...args: Parameters + ): ReturnType[]; +} + +/** + * Create an {@link ISubscribable} that can be instructed to emit events via the {@link IEmitter} interface. + * + * A class can delegate handling {@link ISubscribable} to the returned value while using it to emit the events. + * See also `EventEmitter` which be used as a base class to implement {@link ISubscribable} via extension. + * @internal + */ +export function createEmitter>( + noListeners?: NoListenersCallback, +): ISubscribable & IEmitter & HasListeners { + return new ComposableEventEmitter(noListeners); +} + +/** + * Called when the last listener for `eventName` is removed. + * Useful for determining when to clean up resources related to detecting when the event might occurs. + * @internal + */ +export type NoListenersCallback> = (eventName: keyof Events) => void; + +/** + * @internal + */ +export interface HasListeners> { + /** + * When no `eventName` is provided, returns true iff there are any listeners. + * + * When `eventName` is provided, returns true iff there are listeners for that event. + * + * @remarks + * This can be used to know when its safe to cleanup data-structures which only exist to fire events for their listeners. + */ + hasListeners(eventName?: keyof Events): boolean; +} + +/** + * Provides an API for subscribing to and listening to events. + * + * @remarks Classes wishing to emit events may either extend this class or compose over it. + * + * @example Extending this class + * + * ```typescript + * interface MyEvents { + * "loaded": () => void; + * } + * + * class MyClass extends EventEmitter { + * private load() { + * this.emit("loaded"); + * } + * } + * ``` + * + * @example Composing over this class + * + * ```typescript + * class MyClass implements ISubscribable { + * private readonly events = EventEmitter.create(); + * + * private load() { + * this.events.emit("loaded"); + * } + * + * public on(eventName: K, listener: MyEvents[K]): () => void { + * return this.events.on(eventName, listener); + * } + * } + * ``` + */ +export class EventEmitter> implements ISubscribable, HasListeners { + // TODO: because the inner data-structure here is a set, adding the same callback twice does not error, + // but only calls it once, and unsubscribing will stop calling it all together. + // This is surprising since it makes subscribing and unsubscribing not inverses (but instead both idempotent). + // This might be desired, but if so the documentation should indicate it. + private readonly listeners = new Map any>>(); + + // Because this is protected and not public, calling this externally (not from a subclass) makes sending events to the constructed instance impossible. + // Instead, use the static `create` function to get an instance which allows emitting events. + protected constructor(private readonly noListeners?: NoListenersCallback) {} + + protected emit>(eventName: K, ...args: Parameters): void { + const listeners = this.listeners.get(eventName); + if (listeners !== undefined) { + const argArray: unknown[] = args; // TODO: Current TS (4.5.5) cannot spread `args` into `listener()`, but future versions (e.g. 4.8.4) can. + // This explicitly copies listeners so that new listeners added during this call to emit will not receive this event. + for (const listener of listeners) { + // If listener has been unsubscribed while invoking other listeners, skip it. + if (listeners.has(listener)) { + listener(...argArray); + } + } + } + } + + protected emitAndCollect>( + eventName: K, + ...args: Parameters + ): ReturnType[] { + const listeners = this.listeners.get(eventName); + if (listeners !== undefined) { + const argArray: unknown[] = args; + const resultArray: ReturnType[] = []; + for (const listener of listeners.values()) { + // listner return type is any to enable this.listeners to be a Map + // of Sets rather than a Record with tracked (known) return types. + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + resultArray.push(listener(...argArray)); + } + return resultArray; + } + return []; + } + + /** + * Register an event listener. + * @param eventName - the name of the event + * @param listener - the handler to run when the event is fired by the emitter + * @returns a function which will deregister the listener when run. + * This function will error if called more than once. + * @privateRemarks + * TODO: + * invoking the returned callback can error even if its only called once if the same listener was provided to two calls to "on". + * This behavior is not documented and its unclear if its a bug or not: see note on listeners. + */ + public on>(eventName: K, listener: E[K]): () => void { + getOrCreate(this.listeners, eventName, () => new Set()).add(listener); + return () => this.off(eventName, listener); + } + + private off>(eventName: K, listener: E[K]): void { + const listeners = + this.listeners.get(eventName) ?? + // TODO: consider making this (and assert below) a usage error since it can be triggered by users of the public API: maybe separate those use cases somehow? + fail( + "Event has no listeners. Event deregistration functions may only be invoked once.", + ); + assert( + listeners.delete(listener), + 0x4c1 /* Listener does not exist. Event deregistration functions may only be invoked once. */, + ); + if (listeners.size === 0) { + this.listeners.delete(eventName); + this.noListeners?.(eventName); + } + } + + public hasListeners(eventName?: keyof Events): boolean { + if (eventName === undefined) { + return this.listeners.size > 0; + } + return this.listeners.has(eventName); + } +} + +// This class exposes the constructor and the `emit` method of `EventEmitter`, elevating them from protected to public +class ComposableEventEmitter> extends EventEmitter implements IEmitter { + public constructor(noListeners?: NoListenersCallback) { + super(noListeners); + } + + public override emit>(eventName: K, ...args: Parameters): void { + return super.emit(eventName, ...args); + } + + public override emitAndCollect>( + eventName: K, + ...args: Parameters + ): ReturnType[] { + return super.emitAndCollect(eventName, ...args); + } +} diff --git a/experimental/framework/independent-state/src/index.ts b/experimental/framework/independent-state/src/index.ts index 109ddb289b1c..72bd5fed3bc4 100644 --- a/experimental/framework/independent-state/src/index.ts +++ b/experimental/framework/independent-state/src/index.ts @@ -13,6 +13,7 @@ export type { IndependentMapSchema, } from "./types.js"; +export type { Events, IsEvent, ISubscribable } from "./events.js"; export type { JsonDeserialized } from "./jsonDeserialized.js"; export type { JsonEncodable } from "./jsonEncodable.js"; export type { JsonTypeWith } from "./jsonType.js"; diff --git a/experimental/framework/independent-state/src/latestMapValueManager.ts b/experimental/framework/independent-state/src/latestMapValueManager.ts index 0da793860260..fd0a18c3c99b 100644 --- a/experimental/framework/independent-state/src/latestMapValueManager.ts +++ b/experimental/framework/independent-state/src/latestMapValueManager.ts @@ -3,10 +3,9 @@ * Licensed under the MIT License. */ -import { TypedEventEmitter } from "@fluid-internal/client-utils"; -import type { IEvent, IEventProvider } from "@fluidframework/core-interfaces"; - import type { ClientId } from "./baseTypes.js"; +import type { ISubscribable } from "./events.js"; +import { createEmitter } from "./events.js"; import type { IndependentDatastoreHandle, ManagerFactory, @@ -70,7 +69,7 @@ export interface LatestMapItemRemovedClientData { /** * @beta */ -export interface LatestMapValueManagerEvents extends IEvent { +export interface LatestMapValueManagerEvents { /** * Raised when any item's value for remote client is updated. * @param updates - Map of one or more values updated. @@ -79,7 +78,7 @@ export interface LatestMapValueManagerEvents exten * * @eventProperty */ - (event: "updated", listener: (updates: LatestMapValueClientData) => void): void; + updated: (updates: LatestMapValueClientData) => void; /** * Raised when specific item's value is updated. @@ -87,10 +86,7 @@ export interface LatestMapValueManagerEvents exten * * @eventProperty */ - ( - event: "itemUpdated", - listener: (updatedItem: LatestMapItemValueClientData) => void, - ): void; + itemUpdated: (updatedItem: LatestMapItemValueClientData) => void; /** * Raised when specific item is removed. @@ -98,10 +94,7 @@ export interface LatestMapValueManagerEvents exten * * @eventProperty */ - ( - event: "itemRemoved", - listener: (removedItem: LatestMapItemRemovedClientData) => void, - ): void; + itemRemoved: (removedItem: LatestMapItemRemovedClientData) => void; } /** @@ -289,7 +282,7 @@ export interface LatestMapValueManager>; + readonly events: ISubscribable>; /** * Current value map for this client. @@ -319,7 +312,7 @@ class LatestMapValueManagerImpl< > implements LatestMapValueManager, ValueManager> { - public readonly events = new TypedEventEmitter>(); + public readonly events = createEmitter>(); public constructor( private readonly key: RegistrationKey, diff --git a/experimental/framework/independent-state/src/latestValueManager.ts b/experimental/framework/independent-state/src/latestValueManager.ts index 02f11f828588..70a8081d76e3 100644 --- a/experimental/framework/independent-state/src/latestValueManager.ts +++ b/experimental/framework/independent-state/src/latestValueManager.ts @@ -3,10 +3,9 @@ * Licensed under the MIT License. */ -import { TypedEventEmitter } from "@fluid-internal/client-utils"; -import type { IEvent, IEventProvider } from "@fluidframework/core-interfaces"; - import type { ClientId } from "./baseTypes.js"; +import type { ISubscribable } from "./events.js"; +import { createEmitter } from "./events.js"; import type { IndependentDatastoreHandle, ManagerFactory, @@ -23,13 +22,13 @@ import type { LatestValueClientData, LatestValueData } from "./latestValueTypes. /** * @beta */ -export interface LatestValueManagerEvents extends IEvent { +export interface LatestValueManagerEvents { /** * Raised when remote client's value is updated, which may be the same value. * * @eventProperty */ - (event: "updated", listener: (update: LatestValueClientData) => void): void; + updated: (update: LatestValueClientData) => void; } /** @@ -44,7 +43,7 @@ export interface LatestValueManager { /** * Events for Latest value manager. */ - readonly events: IEventProvider>; + readonly events: ISubscribable>; /** * Current state for this client. @@ -72,7 +71,7 @@ export interface LatestValueManager { class LatestValueManagerImpl implements LatestValueManager, ValueManager> { - public readonly events = new TypedEventEmitter>(); + public readonly events = createEmitter>(); public constructor( private readonly key: Key, diff --git a/experimental/framework/independent-state/src/test/latestMapValueManager.spec.ts b/experimental/framework/independent-state/src/test/latestMapValueManager.spec.ts index 82fd4aabf524..676e2a94d907 100644 --- a/experimental/framework/independent-state/src/test/latestMapValueManager.spec.ts +++ b/experimental/framework/independent-state/src/test/latestMapValueManager.spec.ts @@ -64,8 +64,8 @@ function logClientValue({ localPointers.set("pen", { x: 1, y: 2 }); -pointers.events.on("itemUpdated", logClientValue); -pointers.events.off("itemUpdated", logClientValue); +const pointerItemUpdatedOff = pointers.events.on("itemUpdated", logClientValue); +pointerItemUpdatedOff(); for (const clientId of pointers.clients()) { const clientData = pointers.clientValue(clientId); diff --git a/experimental/framework/independent-state/src/test/latestValueManager.spec.ts b/experimental/framework/independent-state/src/test/latestValueManager.spec.ts index 1eb1ee37e43e..a879bc0c6118 100644 --- a/experimental/framework/independent-state/src/test/latestValueManager.spec.ts +++ b/experimental/framework/independent-state/src/test/latestValueManager.spec.ts @@ -32,7 +32,7 @@ const fakeAdd = map.caret.local.pos + map.camera.local.z + map.cursor.local.x; // @ts-expect-error local may be set wholly, but partially it is readonly map.caret.local.pos = 0; -function logClientValue({ +function logClientValue */>({ clientId, value, }: Pick, "clientId" | "value">): void { @@ -43,8 +43,8 @@ const cursor = map.cursor; cursor.local = { x: 1, y: 2 }; -cursor.events.on("updated", logClientValue); -cursor.events.off("updated", logClientValue); +const cursorUpdatedOff = cursor.events.on("updated", logClientValue); +cursorUpdatedOff(); for (const clientId of cursor.clients()) { logClientValue({ clientId, ...cursor.clientValue(clientId) }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2fab89417c3..744d322094c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5320,7 +5320,6 @@ importers: experimental/framework/independent-state: specifiers: '@arethetypeswrong/cli': ^0.15.2 - '@fluid-internal/client-utils': workspace:~ '@fluid-tools/build-cli': ^0.38.0 '@fluidframework/build-common': ^2.0.3 '@fluidframework/build-tools': ^0.38.0 @@ -5338,7 +5337,6 @@ importers: rimraf: ^4.4.0 typescript: ~5.1.6 dependencies: - '@fluid-internal/client-utils': link:../../../packages/common/client-utils '@fluidframework/container-runtime-definitions': link:../../../packages/runtime/container-runtime-definitions '@fluidframework/core-interfaces': link:../../../packages/common/core-interfaces '@fluidframework/core-utils': link:../../../packages/common/core-utils @@ -5347,11 +5345,11 @@ importers: '@fluidframework/runtime-definitions': link:../../../packages/runtime/runtime-definitions devDependencies: '@arethetypeswrong/cli': 0.15.2 - '@fluid-tools/build-cli': 0.38.0_@types+node@18.19.1 + '@fluid-tools/build-cli': 0.38.0 '@fluidframework/build-common': 2.0.3 - '@fluidframework/build-tools': 0.38.0_@types+node@18.19.1 + '@fluidframework/build-tools': 0.38.0 '@fluidframework/eslint-config-fluid': 5.1.0_bpztyfltmpuv6lhsgzfwtmxhte - '@microsoft/api-extractor': 7.43.1_f66stvskxun56mencgf6l5564y_@types+node@18.19.1 + '@microsoft/api-extractor': 7.43.1_f66stvskxun56mencgf6l5564y copyfiles: 2.4.1 eslint: 8.55.0 prettier: 3.0.3