Skip to content

Commit a1063f9

Browse files
committed
feat(collections): add ReactiveMap
1 parent 277b568 commit a1063f9

6 files changed

Lines changed: 679 additions & 1 deletion

File tree

src/collections/reactiveMap.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { batch } from "../batch";
2+
import { type AddEventListener, event, send, size } from "../event";
3+
import { writable } from "../readable";
4+
import { type Disposer, type OwnedWritable, type Readable } from "../typings";
5+
import { strictEqual } from "../utils";
6+
7+
export interface ReactiveMapChanged<K, V> {
8+
readonly upsert: readonly [K, V][];
9+
readonly delete: readonly K[];
10+
}
11+
12+
export class OwnedReactiveMap<K, V> extends Map<K, V> {
13+
/**
14+
* A Readable that emits the map itself whenever it changes.
15+
*/
16+
public get $(): Readable<ReactiveMap<K, V>> {
17+
return (this._$ ??= writable(this, { equal: false }));
18+
}
19+
20+
/**
21+
* Subscribe to changes in the map.
22+
*
23+
* @param fn - The function to call when the map is changed.
24+
* @returns A disposer function to unsubscribe from the event.
25+
*/
26+
public onChanged(fn: (changed: ReactiveMapChanged<K, V>) => void): Disposer {
27+
if (!this._onChanged_) {
28+
this._onChanged_ = event({ delete: new Set(), upsert: new Map() });
29+
const handler = () => {
30+
if (this._onChanged_ && size(this._onChanged_)) {
31+
const { data_ } = this._onChanged_;
32+
if (data_.upsert.size > 0 || data_.delete.size > 0) {
33+
const changedData = {
34+
upsert: [...data_.upsert],
35+
delete: [...data_.delete],
36+
};
37+
data_.upsert.clear();
38+
data_.delete.clear();
39+
send(this._onChanged_, changedData);
40+
}
41+
} else {
42+
this._onChanged_ = undefined;
43+
this.$.unsubscribe(handler);
44+
}
45+
};
46+
this.$.onReaction_(handler);
47+
}
48+
return this._onChanged_(fn);
49+
}
50+
51+
/**
52+
* Subscribe to value removal events.
53+
* This is useful for further processing values that are removed from the map.
54+
*
55+
* A value is considered removed when:
56+
* - it is deleted from the map.
57+
* - it is replaced by another value (the old value is removed).
58+
* - it is cleared from the map.
59+
*
60+
* Note that for performance reasons, it does not handle the case where multiple keys map to the same value.
61+
*
62+
* @param fn - The function to call when a value is removed.
63+
* @returns A disposer function to unsubscribe from the event.
64+
*/
65+
public onValueRemoved(fn: (value: V) => void): Disposer {
66+
if (!this._onValueRemoved_) {
67+
this._onValueRemoved_ = event(new Set<V>());
68+
const handler = () => {
69+
if (this._onValueRemoved_ && size(this._onValueRemoved_)) {
70+
const { data_ } = this._onValueRemoved_;
71+
if (data_.size) {
72+
const removedValues = [...data_];
73+
data_.clear();
74+
for (const value of removedValues) {
75+
send(this._onValueRemoved_, value);
76+
}
77+
}
78+
} else {
79+
this._onValueRemoved_ = undefined;
80+
this.$.unsubscribe(handler);
81+
}
82+
};
83+
this.$.onReaction_(handler);
84+
}
85+
return this._onValueRemoved_(fn);
86+
}
87+
88+
public constructor(entries?: readonly (readonly [K, V])[] | null) {
89+
super();
90+
91+
if (entries) {
92+
batch(() => {
93+
for (const [key, value] of entries) {
94+
this.set(key, value);
95+
}
96+
});
97+
}
98+
}
99+
100+
public dispose(): void {
101+
this._$?.dispose();
102+
this._onChanged_ = this._onValueRemoved_ = undefined;
103+
super.clear();
104+
}
105+
106+
public override set(key: K, value: V): this {
107+
if (this.has(key)) {
108+
const oldValue = this.get(key)!;
109+
if (!strictEqual(oldValue, value)) {
110+
this._onValueRemoved_?.data_.add(oldValue);
111+
this._upsert_(key, value);
112+
}
113+
} else {
114+
this._upsert_(key, value);
115+
}
116+
return this;
117+
}
118+
119+
public override delete(key: K): boolean {
120+
if (this.has(key)) {
121+
this._onValueRemoved_?.data_.add(this.get(key)!);
122+
this._onChanged_?.data_.delete.add(key);
123+
this._onChanged_?.data_.upsert.delete(key);
124+
this._$?.set(this);
125+
}
126+
return super.delete(key);
127+
}
128+
129+
public override clear(): void {
130+
if (this.size) {
131+
if (this._onValueRemoved_ || this._onChanged_) {
132+
for (const [key, value] of this) {
133+
this._onValueRemoved_?.data_.add(value);
134+
this._onChanged_?.data_.delete.add(key);
135+
this._onChanged_?.data_.upsert.delete(key);
136+
}
137+
}
138+
super.clear();
139+
this._$?.set(this);
140+
}
141+
}
142+
143+
public rename(key: K, newKey: K): void {
144+
batch(() => {
145+
if (this.has(key)) {
146+
const value = this.get(key)!;
147+
this.delete(key);
148+
this.set(newKey, value);
149+
}
150+
});
151+
}
152+
153+
/** @internal */
154+
private _$?: OwnedWritable<this>;
155+
156+
/** @internal */
157+
private _onChanged_?: AddEventListener<
158+
ReactiveMapChanged<K, V>,
159+
{
160+
readonly upsert: Map<K, V>;
161+
readonly delete: Set<K>;
162+
}
163+
>;
164+
165+
/** @internal */
166+
private _onValueRemoved_?: AddEventListener<V, Set<V>>;
167+
168+
/** @internal */
169+
private _upsert_(key: K, value: V): void {
170+
this._onValueRemoved_?.data_.delete(value);
171+
this._onChanged_?.data_.upsert.set(key, value);
172+
this._onChanged_?.data_.delete.delete(key);
173+
super.set(key, value);
174+
this._$?.set(this);
175+
}
176+
}
177+
178+
export type ReactiveMap<K, V> = Omit<OwnedReactiveMap<K, V>, "dispose">;
179+
180+
export const reactiveMap = <K, V>(entries?: readonly (readonly [K, V])[] | null): OwnedReactiveMap<K, V> =>
181+
new OwnedReactiveMap(entries);

src/event.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { type Disposer } from "./typings";
2+
import { invokeEach } from "./utils";
3+
4+
export interface Listener<T = any> {
5+
(data: T): void;
6+
}
7+
8+
export interface AddEventListener<T = any, D = any> {
9+
(listener: Listener<T>): Disposer;
10+
/** @internal */
11+
multi_?: Set<Listener<T>>;
12+
/** @internal */
13+
single_?: Listener<T> | null;
14+
/** @internal */
15+
data_: D;
16+
}
17+
18+
export function event<T = any, D = any>(data: D): AddEventListener<T, D> {
19+
function addEventListener(listener: Listener<T>): Disposer {
20+
return on(addEventListener, listener);
21+
}
22+
addEventListener.data_ = data;
23+
return addEventListener;
24+
}
25+
26+
export function send<T = any>(addEventListener: AddEventListener<T>, data: T): void {
27+
addEventListener.multi_ ? invokeEach(addEventListener.multi_, data) : addEventListener.single_?.(data);
28+
}
29+
30+
export function size(addEventListener: AddEventListener): number {
31+
return addEventListener.multi_ ? addEventListener.multi_.size : addEventListener.single_ ? 1 : 0;
32+
}
33+
34+
export function on(addEventListener: AddEventListener, listener: Listener): Disposer {
35+
addEventListener.single_ || addEventListener.multi_
36+
? (addEventListener.multi_ ??= new Set<Listener>().add(addEventListener.single_!)).add(listener)
37+
: (addEventListener.single_ = listener);
38+
return off.bind(addEventListener, listener);
39+
}
40+
41+
function off(this: AddEventListener, listener: Listener): void {
42+
this.multi_ ? this.multi_.delete(listener) : this.single_ === listener && (this.single_ = null);
43+
}

src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,10 @@ export type {
1818

1919
export { readable, writable, toWritable } from "./readable";
2020
export { watch, type WatchEffect } from "./watch";
21+
22+
export {
23+
reactiveMap,
24+
type OwnedReactiveMap,
25+
type ReactiveMap,
26+
type ReactiveMapChanged,
27+
} from "./collections/reactiveMap";

src/utils.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,17 @@ interface IsReadable {
8383
* @returns `true` if $ is Readable.
8484
*/
8585
export const isReadable: IsReadable = ($: unknown): $ is Readable => ($ as Readable | undefined)?.[BRAND] === BRAND;
86+
87+
export const invokeEach = <T>(iterable: Iterable<(value: T) => any>, value: T) => {
88+
let error: unknown = UNIQUE_VALUE;
89+
for (const fn of iterable) {
90+
try {
91+
fn(value);
92+
} catch (e) {
93+
error = e;
94+
}
95+
}
96+
if (error !== UNIQUE_VALUE) {
97+
throw error;
98+
}
99+
};

0 commit comments

Comments
 (0)