UNPKG

@wordpress/core-data

Version:
343 lines (298 loc) 9.44 kB
/** * Internal dependencies */ import { REMOVAL_DELAY_IN_MS } from './config'; import { TypedAwareness } from './typed-awareness'; import type { EnhancedState, EqualityFieldCheck } from './types'; import { getTypedKeys, areMapsEqual } from './utils'; type AwarenessClientID = number; interface AwarenessStateChange { added: AwarenessClientID[]; updated: AwarenessClientID[]; removed: AwarenessClientID[]; } abstract class AwarenessWithEqualityChecks< State extends object, > extends TypedAwareness< State > { /** OVERRIDDEN METHODS */ /** * Set a local state field on an awareness document. Calling this method may * trigger rerenders of any subscribed components. * * Equality checks are provided by the abstract `equalityFieldChecks` property. * @param field - The field to set. * @param value - The value to set. */ public setLocalStateField< FieldName extends string & keyof State >( field: FieldName, value: State[ FieldName ] ): void { if ( this.isFieldEqual( field, value, this.getLocalStateField( field ) ?? undefined ) ) { return; } super.setLocalStateField( field, value ); } /** ABSTRACT PROPERTIES */ /** * Extending classes must implement equality checks for each awareness state * field they manage. */ protected abstract equalityFieldChecks: { [ FieldName in keyof State ]: EqualityFieldCheck< State, FieldName >; }; /** CUSTOM METHODS */ /** * Determine if a field value has changed using the provided equality checks. * @param field - The field to check. * @param value1 - The first value to compare. * @param value2 - The second value to compare. */ protected isFieldEqual< FieldName extends keyof State >( field: FieldName, value1?: State[ FieldName ], value2?: State[ FieldName ] ): boolean { if ( [ 'clientId', 'isConnected', 'isMe' ].includes( field as string ) ) { return value1 === value2; } if ( field in this.equalityFieldChecks ) { const fn = this.equalityFieldChecks[ field ]; return fn( value1, value2 ); } throw new Error( `No equality check implemented for awareness state field "${ field.toString() }".` ); } /** * Determine if two states are equal by comparing each field using the * provided equality checks. * @param state1 - The first state to compare. * @param state2 - The second state to compare. */ protected isStateEqual( state1: State, state2: State ): boolean { return [ ...new Set< keyof State >( [ ...getTypedKeys( state1 ), ...getTypedKeys( state2 ), ] ), ].every( ( field ) => { const value1 = state1[ field ]; const value2 = state2[ field ]; return this.isFieldEqual( field, value1, value2 ); } ); } } /** * Abstract class to manage awareness and allow external code to subscribe to * state updates. */ export abstract class AwarenessState< State extends object = {}, > extends AwarenessWithEqualityChecks< State > { /** CUSTOM PROPERTIES */ /** * Whether the setUp method has been called, to avoid running it multiple * times. */ private hasSetupRun = false; /** * We keep track of all seen states during the current session for two reasons: * * 1. So that we can represent recently disconnected collaborators in our UI, even * after they have been removed from the awareness document. * 2. So that we can provide debug information about all collaborators seen during * the session. */ private disconnectedCollaborators: Set< number > = new Set(); private seenStates: Map< number, State > = new Map(); /** * Hold a snapshot of the previous awareness state allows us to compare the * state values and avoid unnecessary updates to subscribers. */ private previousSnapshot = new Map< number, EnhancedState< State > >(); private stateSubscriptions: Array< ( newState: EnhancedState< State >[] ) => void > = []; /** * In some cases, we may want to throttle setting local state fields to avoid * overwhelming the awareness document with rapid updates. At the same time, we * want to ensure that when we read our own state locally, we get the latest * value -- even if it hasn't yet been set on the awareness instance. */ private myThrottledState: Partial< State > = {}; private throttleTimeouts: Map< string, NodeJS.Timeout > = new Map(); /** CUSTOM METHODS */ /** * Set up the awareness state. This method is idempotent and will only run * once. Subclasses should override `onSetUp()` instead of this method to * add their own setup logic. * * This is defined as a readonly arrow function property to prevent * subclasses from overriding it. */ public readonly setUp = (): void => { if ( this.hasSetupRun ) { return; } this.hasSetupRun = true; this.onSetUp(); this.on( 'change', ( { added, removed, updated }: AwarenessStateChange ) => { [ ...added, ...updated ].forEach( ( id ) => { this.disconnectedCollaborators.delete( id ); } ); removed.forEach( ( id ) => { this.disconnectedCollaborators.add( id ); setTimeout( () => { this.disconnectedCollaborators.delete( id ); this.updateSubscribers( true /* force update */ ); }, REMOVAL_DELAY_IN_MS ); } ); // Do not force-update the store here, since this change handler can be // called even when there are no actual state changes. this.updateSubscribers(); } ); }; /** * Hook method for subclasses to add their own setup logic. This is called * once after the base class setup completes. All subclasses must implement * this method. If extending a class that already implements `onSetUp()`, * call `super.onSetUp()` to ensure parent setup runs. */ protected abstract onSetUp(): void; /** * Get the most recent state from the last processed change event. * * @return An array of EnhancedState< State >. */ public getCurrentState(): EnhancedState< State >[] { return Array.from( this.previousSnapshot.values() ); } /** * Get all seen states in this session to enable debug reporting. */ public getSeenStates(): Map< number, State > { return this.seenStates; } /** * Allow external code to subscribe to awareness state changes. * @param callback - The callback to subscribe to. */ public onStateChange( callback: ( newState: EnhancedState< State >[] ) => void ): () => void { this.stateSubscriptions.push( callback ); return () => { this.stateSubscriptions = this.stateSubscriptions.filter( ( cb ) => cb !== callback ); }; } /** * Set a local state field on an awareness document with throttle. See caveats * of this.setLocalStateField. * @param field - The field to set. * @param value - The value to set. * @param wait - The wait time in milliseconds. */ public setThrottledLocalStateField< FieldName extends string & keyof State, >( field: FieldName, value: State[ FieldName ], wait: number ): void { this.setLocalStateField( field, value ); this.throttleTimeouts.set( field, setTimeout( () => { this.throttleTimeouts.delete( field ); if ( this.myThrottledState[ field ] ) { this.setLocalStateField( field, this.myThrottledState[ field ] ); delete this.myThrottledState[ field ]; } }, wait ) ); } /** * Set the current collaborator's connection status as awareness state. * @param isConnected - The connection status. */ public setConnectionStatus( isConnected: boolean ): void { if ( isConnected ) { this.disconnectedCollaborators.delete( this.clientID ); } else { this.disconnectedCollaborators.add( this.clientID ); } this.updateSubscribers( true /* force update */ ); } /** * Update all subscribed listeners with the latest awareness state. * @param forceUpdate - Whether to force an update. */ protected updateSubscribers( forceUpdate = false ): void { if ( ! this.stateSubscriptions.length ) { return; } const states = this.getStates(); this.seenStates = new Map< number, State >( [ ...this.seenStates.entries(), ...states.entries(), ] ); const updatedStates = new Map< number, EnhancedState< State > >( [ ...this.disconnectedCollaborators, ...states.keys() ] .filter( ( clientId ) => { // Exclude any collaborators with empty awareness state. This can happen from // the Yjs inspector. return ( Object.keys( this.seenStates.get( clientId ) ?? {} ) .length > 0 ); } ) .map( ( clientId ) => { // The filter above ensures that seenStates has the clientId. const rawState: State = this.seenStates.get( clientId )!; const isConnected = ! this.disconnectedCollaborators.has( clientId ); const isMe = clientId === this.clientID; const myState: Partial< State > = isMe ? this.myThrottledState : {}; const state: EnhancedState< State > = { ...rawState, ...myState, clientId, isConnected, isMe, }; return [ clientId, state ]; } ) ); if ( ! forceUpdate ) { if ( areMapsEqual( this.previousSnapshot, updatedStates, this.isStateEqual.bind( this ) ) ) { // Awareness state unchanged, do not update subscribers. return; } } // Update subscribers. this.previousSnapshot = updatedStates; this.stateSubscriptions.forEach( ( callback ) => { callback( Array.from( updatedStates.values() ) ); } ); } }