@wordpress/core-data
Version:
Access to and manipulation of core WordPress entities.
343 lines (298 loc) • 9.44 kB
text/typescript
/**
* 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() ) );
} );
}
}