mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
feat(ui): add SyncableMap
Can be used with useSyncExternal store to make a `Map` reactive.
This commit is contained in:
parent
a6d73d0773
commit
46bfbbbc87
@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { SyncableMap } from './SyncableMap';
|
||||
|
||||
describe('SyncableMap', () => {
|
||||
it('should initialize with entries', () => {
|
||||
const initialEntries = [
|
||||
['key1', 'value1'],
|
||||
['key2', 'value2'],
|
||||
] as const;
|
||||
const map = new SyncableMap(initialEntries);
|
||||
expect(map.size).toBe(2);
|
||||
expect(map.get('key1')).toBe('value1');
|
||||
expect(map.get('key2')).toBe('value2');
|
||||
});
|
||||
|
||||
it('should notify subscribers when a key is set', () => {
|
||||
const map = new SyncableMap<string, string>();
|
||||
const subscriber = vi.fn();
|
||||
map.subscribe(subscriber);
|
||||
|
||||
map.set('key1', 'value1');
|
||||
|
||||
expect(subscriber).toHaveBeenCalledTimes(1);
|
||||
expect(map.get('key1')).toBe('value1');
|
||||
});
|
||||
|
||||
it('should notify subscribers when a key is deleted', () => {
|
||||
const map = new SyncableMap<string, string>([['key1', 'value1']]);
|
||||
const subscriber = vi.fn();
|
||||
map.subscribe(subscriber);
|
||||
|
||||
map.delete('key1');
|
||||
|
||||
expect(subscriber).toHaveBeenCalledTimes(1);
|
||||
expect(map.get('key1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should notify subscribers when the map is cleared', () => {
|
||||
const map = new SyncableMap<string, string>([
|
||||
['key1', 'value1'],
|
||||
['key2', 'value2'],
|
||||
]);
|
||||
const subscriber = vi.fn();
|
||||
map.subscribe(subscriber);
|
||||
|
||||
map.clear();
|
||||
|
||||
expect(subscriber).toHaveBeenCalledTimes(1);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should not notify unsubscribed callbacks', () => {
|
||||
const map = new SyncableMap<string, string>();
|
||||
const subscriber = vi.fn();
|
||||
const unsubscribe = map.subscribe(subscriber);
|
||||
|
||||
unsubscribe();
|
||||
|
||||
map.set('key1', 'value1');
|
||||
|
||||
expect(subscriber).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return a snapshot of the current state', () => {
|
||||
const map = new SyncableMap<string, string>([['key1', 'value1']]);
|
||||
|
||||
const snapshot = map.getSnapshot();
|
||||
|
||||
expect(snapshot.size).toBe(1);
|
||||
expect(snapshot.get('key1')).toBe('value1');
|
||||
});
|
||||
|
||||
it('should return the same snapshot if there were no changes', () => {
|
||||
const map = new SyncableMap<string, string>([['key1', 'value1']]);
|
||||
|
||||
const firstSnapshot = map.getSnapshot();
|
||||
const secondSnapshot = map.getSnapshot();
|
||||
|
||||
expect(firstSnapshot).toBe(secondSnapshot);
|
||||
});
|
||||
|
||||
it('should return a new snapshot if changes were made', () => {
|
||||
const map = new SyncableMap<string, string>([['key1', 'value1']]);
|
||||
|
||||
const firstSnapshot = map.getSnapshot();
|
||||
map.set('key2', 'value2');
|
||||
const secondSnapshot = map.getSnapshot();
|
||||
|
||||
expect(firstSnapshot).not.toBe(secondSnapshot);
|
||||
expect(secondSnapshot.size).toBe(2);
|
||||
});
|
||||
|
||||
it('should consider different snapshots unequal', () => {
|
||||
const map = new SyncableMap<string, string>([['key1', 'value1']]);
|
||||
|
||||
const firstSnapshot = map.getSnapshot();
|
||||
map.set('key2', 'value2');
|
||||
const secondSnapshot = map.getSnapshot();
|
||||
|
||||
expect(map['areSnapshotsEqual'](firstSnapshot, secondSnapshot)).toBe(false);
|
||||
});
|
||||
|
||||
it('should consider identical snapshots equal', () => {
|
||||
const map = new SyncableMap<string, string>([
|
||||
['key1', 'value1'],
|
||||
['key2', 'value2'],
|
||||
]);
|
||||
|
||||
const firstSnapshot = map.getSnapshot();
|
||||
const secondSnapshot = map.getSnapshot();
|
||||
|
||||
expect(map['areSnapshotsEqual'](firstSnapshot, secondSnapshot)).toBe(true);
|
||||
});
|
||||
});
|
@ -0,0 +1,86 @@
|
||||
/**
|
||||
* A Map that allows for subscribing to changes and getting a snapshot of the current state.
|
||||
*
|
||||
* It can be used with the `useSyncExternalStore` hook to sync the state of the map with a React component.
|
||||
*
|
||||
* Reactivity is shallow, so changes to nested objects will not trigger a re-render.
|
||||
*/
|
||||
export class SyncableMap<K, V> extends Map<K, V> {
|
||||
private subscriptions = new Set<() => void>();
|
||||
private lastSnapshot: Map<K, V> | null = null;
|
||||
|
||||
constructor(entries?: readonly (readonly [K, V])[] | null) {
|
||||
super(entries);
|
||||
}
|
||||
|
||||
set = (key: K, value: V): this => {
|
||||
super.set(key, value);
|
||||
this.notifySubscribers();
|
||||
return this;
|
||||
};
|
||||
|
||||
delete = (key: K): boolean => {
|
||||
const result = super.delete(key);
|
||||
this.notifySubscribers();
|
||||
return result;
|
||||
};
|
||||
|
||||
clear = (): void => {
|
||||
super.clear();
|
||||
this.notifySubscribers();
|
||||
};
|
||||
|
||||
/**
|
||||
* Notify all subscribers that the map has changed.
|
||||
*/
|
||||
private notifySubscribers = () => {
|
||||
for (const callback of this.subscriptions) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to changes to the map.
|
||||
* @param callback A function to call when the map changes
|
||||
* @returns A function to unsubscribe from changes
|
||||
*/
|
||||
subscribe = (callback: () => void): (() => void) => {
|
||||
this.subscriptions.add(callback);
|
||||
return () => {
|
||||
this.subscriptions.delete(callback);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a snapshot of the current state of the map.
|
||||
* @returns A snapshot of the current state of the map
|
||||
*/
|
||||
getSnapshot = (): Map<K, V> => {
|
||||
const currentSnapshot = new Map(this);
|
||||
if (!this.lastSnapshot || !this.areSnapshotsEqual(this.lastSnapshot, currentSnapshot)) {
|
||||
this.lastSnapshot = currentSnapshot;
|
||||
}
|
||||
|
||||
return this.lastSnapshot;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compare two snapshots to determine if they are equal.
|
||||
* @param snapshotA The first snapshot to compare
|
||||
* @param snapshotB The second snapshot to compare
|
||||
* @returns Whether the two snapshots are equal
|
||||
*/
|
||||
private areSnapshotsEqual = (snapshotA: Map<K, V>, snapshotB: Map<K, V>): boolean => {
|
||||
if (snapshotA.size !== snapshotB.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [key, value] of snapshotA) {
|
||||
if (!Object.is(value, snapshotB.get(key))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue
Block a user