stream-chat
Version:
JS SDK for the Stream Chat API
304 lines (266 loc) • 10.8 kB
text/typescript
export type Patch<T> = (value: T) => T;
export type ValueOrPatch<T> = T | Patch<T>;
export type Handler<T> = (nextValue: T, previousValue: T | undefined) => void;
export type Unsubscribe = () => void;
// aliases
export type RemovePreprocessor = Unsubscribe;
export type Preprocessor<T> = Handler<T>;
export const isPatch = <T>(value: ValueOrPatch<T>): value is Patch<T> =>
typeof value === 'function';
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {};
export class StateStore<T extends Record<string, unknown>> {
protected handlers = new Set<Handler<T>>();
protected preprocessors = new Set<Preprocessor<T>>();
constructor(protected value: T) {}
/**
* Allows merging two stores only if their keys differ otherwise there's no way to ensure the data type stability.
* @experimental
* This method is experimental and may change in future versions.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public merge<Q extends StateStore<any>>(
stateStore: Q extends StateStore<infer L>
? Extract<keyof T, keyof L> extends never
? Q
: never
: never,
) {
return new MergedStateStore<T, Q extends StateStore<infer L> ? L : never>({
original: this,
merged: stateStore,
});
}
public next(newValueOrPatch: ValueOrPatch<T>): void {
// newValue (or patch output) should never be a mutated previous value
const newValue = isPatch(newValueOrPatch)
? newValueOrPatch(this.value)
: newValueOrPatch;
// do not notify subscribers if the value hasn't changed
if (newValue === this.value) return;
this.preprocessors.forEach((preprocessor) => preprocessor(newValue, this.value));
const oldValue = this.value;
this.value = newValue;
this.handlers.forEach((handler) => handler(this.value, oldValue));
}
public partialNext = (partial: Partial<T>): void =>
this.next((current) => ({ ...current, ...partial }));
public getLatestValue(): T {
return this.value;
}
public subscribe(handler: Handler<T>): Unsubscribe {
handler(this.value, undefined);
this.handlers.add(handler);
return () => {
this.handlers.delete(handler);
};
}
public subscribeWithSelector = <
O extends Readonly<Record<string, unknown>> | Readonly<unknown[]>,
>(
selector: (nextValue: T) => O,
handler: Handler<O>,
) => {
// begin with undefined to reduce amount of selector calls
let previouslySelectedValues: O | undefined;
const wrappedHandler: Handler<T> = (nextValue) => {
const newlySelectedValues = selector(nextValue);
let hasUpdatedValues = typeof previouslySelectedValues === 'undefined';
for (const key in previouslySelectedValues) {
if (previouslySelectedValues[key] === newlySelectedValues[key]) continue;
hasUpdatedValues = true;
break;
}
if (!hasUpdatedValues) return;
// save a copy of previouslySelectedValues before running
// handler - if previouslySelectedValues are set to
// newlySelectedValues after the handler call, there's a chance
// that it'll never get set as handler can throw and flow might
// go out of sync
const previouslySelectedValuesCopy = previouslySelectedValues;
previouslySelectedValues = newlySelectedValues;
handler(newlySelectedValues, previouslySelectedValuesCopy);
};
return this.subscribe(wrappedHandler);
};
/**
* Registers a preprocessor function that will be called before the state is updated.
*
* Preprocessors are invoked with the new and previous values whenever `next` or `partialNext` methods
* are called, allowing you to mutate or react to the new value before it is set. Preprocessors run in the
* order they were registered.
*
* @example
* ```ts
* const store = new StateStore<{ count: number; isMaxValue: bool; }>({ count: 0, isMaxValue: false });
*
* store.addPreprocessor((nextValue, prevValue) => {
* if (nextValue.count > 10) {
* nextValue.count = 10; // Clamp the value to a maximum of 10
* }
*
* if (nextValue.count === 10) {
* nextValue.isMaxValue = true; // Set isMaxValue to true if count is 10
* } else {
* nextValue.isMaxValue = false; // Reset isMaxValue otherwise
* }
* });
*
* store.partialNext({ count: 15 });
*
* store.getLatestValue(); // { count: 10, isMaxValue: true }
*
* store.partialNext({ count: 5 });
*
* store.getLatestValue(); // { count: 5, isMaxValue: false }
* ```
*
* @param preprocessor - The function to be called with the next and previous values before the state is updated.
* @returns A `RemovePreprocessor` function that removes the preprocessor when called.
*/
public addPreprocessor(preprocessor: Preprocessor<T>): RemovePreprocessor {
this.preprocessors.add(preprocessor);
return () => {
this.preprocessors.delete(preprocessor);
};
}
}
/**
* Represents a merged state store that combines two separate state stores into one.
*
* The MergedStateStore allows combining two stores with non-overlapping keys.
* It extends StateStore with the combined type of both source stores.
* Changes to either the original or merged store will propagate to the combined store.
*
* Note: Direct mutations (next, partialNext, addPreprocessor) are disabled on the merged store.
* You should instead call these methods on the original or merged stores.
*
* @template O The type of the original state store
* @template M The type of the merged state store
*
* @experimental
* This class is experimental and may change in future versions.
*/
export class MergedStateStore<
O extends Record<string, unknown>,
M extends Record<string, unknown>,
> extends StateStore<O & M> {
public readonly original: StateStore<O>;
public readonly merged: StateStore<M>;
private cachedOriginalValue: O;
private cachedMergedValue: M;
constructor({ original, merged }: { original: StateStore<O>; merged: StateStore<M> }) {
const originalValue = original.getLatestValue();
const mergedValue = merged.getLatestValue();
super({
...originalValue,
...mergedValue,
});
this.cachedOriginalValue = originalValue;
this.cachedMergedValue = mergedValue;
this.original = original;
this.merged = merged;
}
/**
* Subscribes to changes in the merged state store.
*
* This method extends the base subscribe functionality to handle the merged nature of this store:
* 1. The first subscriber triggers registration of helper subscribers that listen to both source stores
* 2. Changes from either source store are propagated to this merged store
* 3. Source store values are cached to prevent unnecessary updates
*
* When the first subscriber is added, the method sets up listeners on both original and merged stores.
* These listeners update the combined store value whenever either source store changes.
* All subscriptions (helpers and the actual handler) are tracked so they can be properly cleaned up.
*
* @param handler - The callback function that will be executed when the state changes
* @returns An unsubscribe function that, when called, removes the subscription and any helper subscriptions
*/
public subscribe(handler: Handler<O & M>) {
const unsubscribeFunctions: Unsubscribe[] = [];
// first subscriber will also register helpers which listen to changes of the
// "original" and "merged" stores, combined outputs will be emitted through super.next
// whenever cached values do not equal (always apart from the initial subscription)
// since the actual handler subscription is registered after helpers, the actual
// handler will run only once
if (!this.handlers.size) {
const base = (nextValue: O | M) => {
super.next((currentValue) => ({
...currentValue,
...nextValue,
}));
};
unsubscribeFunctions.push(
this.original.subscribe((nextValue) => {
if (nextValue === this.cachedOriginalValue) return;
this.cachedOriginalValue = nextValue;
base(nextValue);
}),
this.merged.subscribe((nextValue) => {
if (nextValue === this.cachedMergedValue) return;
this.cachedMergedValue = nextValue;
base(nextValue);
}),
);
}
unsubscribeFunctions.push(super.subscribe(handler));
return () => {
unsubscribeFunctions.forEach((unsubscribe) => unsubscribe());
};
}
/**
* Retrieves the latest combined state from both original and merged stores.
*
* This method extends the base getLatestValue functionality to ensure the merged store
* remains in sync with its source stores even when there are no active subscribers.
*
* When there are no handlers registered, the method:
* 1. Fetches the latest values from both source stores
* 2. Compares them with the cached values to detect changes
* 3. If changes are detected, updates the internal value and caches
* the new source values to maintain consistency
*
* This approach ensures that calling getLatestValue() always returns the most
* up-to-date combined state, even if the merged store hasn't been actively
* receiving updates through subscriptions.
*
* @returns The latest combined state from both original and merged stores
*/
public getLatestValue() {
// if there are no handlers registered to MergedStore then the local value might be out-of-sync
// pull latest and compare against cached - if they differ, cache latest and produce new combined
if (!this.handlers.size) {
const originalValue = this.original.getLatestValue();
const mergedValue = this.merged.getLatestValue();
if (
originalValue !== this.cachedOriginalValue ||
mergedValue !== this.cachedMergedValue
) {
this.value = {
...originalValue,
...mergedValue,
};
this.cachedMergedValue = mergedValue;
this.cachedOriginalValue = originalValue;
}
}
return super.getLatestValue();
}
// override original methods and "disable" them
public next = () => {
console.warn(
`${MergedStateStore.name}.next is disabled, call original.next or merged.next instead`,
);
};
public partialNext = () => {
console.warn(
`${MergedStateStore.name}.partialNext is disabled, call original.partialNext or merged.partialNext instead`,
);
};
public addPreprocessor() {
console.warn(
`${MergedStateStore.name}.addPreprocessor is disabled, call original.addPreprocessor or merged.addPreprocessor instead`,
);
return noop;
}
}