@realglebivanov/reactive 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.tool-versions +1 -0
- package/LICENSE +20 -0
- package/README.md +158 -0
- package/package.json +46 -0
- package/public/Kasha.png +0 -0
- package/public/KashaHard.gif +0 -0
- package/public/index.html +13 -0
- package/src/example.ts +133 -0
- package/src/index.ts +6 -0
- package/src/lifecycle.ts +44 -0
- package/src/nodes/component.ts +107 -0
- package/src/nodes/cond.ts +79 -0
- package/src/nodes/index.ts +7 -0
- package/src/nodes/iterable/collection.ts +106 -0
- package/src/nodes/iterable/item.ts +35 -0
- package/src/nodes/iterable.ts +84 -0
- package/src/nodes/reactive.ts +97 -0
- package/src/nodes/template.ts +95 -0
- package/src/observables/dedup.observable.ts +65 -0
- package/src/observables/index.ts +18 -0
- package/src/observables/map.observable.ts +80 -0
- package/src/observables/scoped.observable.ts +49 -0
- package/src/observables/value.observable.ts +50 -0
- package/src/reactive/array.ts +63 -0
- package/src/reactive/index.ts +1 -0
- package/src/router.ts +82 -0
- package/src/tag.ts +62 -0
- package/src/task.ts +36 -0
- package/tsconfig.json +42 -0
- package/tsup.config.js +21 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { ReactiveNode } from "../reactive";
|
|
2
|
+
import { ReactiveItem } from "./item";
|
|
3
|
+
|
|
4
|
+
export type Key = string | number | boolean | symbol;
|
|
5
|
+
export type KeyFn<K extends Key, T> = ((key: Key, value: T) => K);
|
|
6
|
+
export type BuildFn<N extends Node, T> = ((key: Key, value: T) => ReactiveNode<N>);
|
|
7
|
+
export type Collection<T> = Array<T> | Map<Key, T>;
|
|
8
|
+
|
|
9
|
+
export class ReactiveItemCollection<K extends Key, T, N extends ReactiveNode<Node>> {
|
|
10
|
+
private generationId = 0;
|
|
11
|
+
private items = new Map<K, ReactiveItem<T, N>>();
|
|
12
|
+
|
|
13
|
+
constructor(
|
|
14
|
+
private keyFn: KeyFn<K, T>,
|
|
15
|
+
private buildFn: BuildFn<N, T>,
|
|
16
|
+
) { }
|
|
17
|
+
|
|
18
|
+
deactivate() {
|
|
19
|
+
for (const item of this.items.values())
|
|
20
|
+
item.deactivate();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
unmount() {
|
|
24
|
+
for (const item of this.items.values())
|
|
25
|
+
item.unmount();
|
|
26
|
+
this.items.clear();
|
|
27
|
+
this.generationId = 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
replace(anchor: Node, newItems: Collection<T>) {
|
|
31
|
+
this.generationId++;
|
|
32
|
+
let refItem = null;
|
|
33
|
+
|
|
34
|
+
for (const [k, value] of newItems.entries()) {
|
|
35
|
+
const key = this.keyFn(k, value);
|
|
36
|
+
const item = this.getOrInsert(anchor, refItem, key, value);
|
|
37
|
+
item.generationId = this.generationId;
|
|
38
|
+
refItem = item;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.removeStaleItems();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
replaceKeys(anchor: Node, items: Collection<T>) {
|
|
45
|
+
for (const [k, value] of items.entries()) {
|
|
46
|
+
const key = this.keyFn(k, value);
|
|
47
|
+
const refItem = this.items.get(key);
|
|
48
|
+
|
|
49
|
+
if (refItem === undefined) continue;
|
|
50
|
+
|
|
51
|
+
this.insertItem(anchor, refItem, key, value);
|
|
52
|
+
refItem.deactivate();
|
|
53
|
+
refItem.unmount();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
append(anchor: Node, newItems: Collection<T>) {
|
|
58
|
+
for (const [k, value] of newItems.entries()) {
|
|
59
|
+
const key = this.keyFn(k, value);
|
|
60
|
+
this.insertItem(anchor, null, key, value);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
remove(items: Collection<T>) {
|
|
65
|
+
for (const [k, value] of items.entries()) {
|
|
66
|
+
const key = this.keyFn(k, value);
|
|
67
|
+
const item = this.items.get(key);
|
|
68
|
+
if (item === undefined) continue;
|
|
69
|
+
item.deactivate();
|
|
70
|
+
item.unmount();
|
|
71
|
+
this.items.delete(key);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private getOrInsert(anchor: Node, refItem: ReactiveItem<T, N> | null, key: K, value: T) {
|
|
76
|
+
const item = this.items.get(key);
|
|
77
|
+
|
|
78
|
+
if (item === undefined) return this.insertItem(anchor, refItem, key, value);
|
|
79
|
+
if (item.value === value) return item;
|
|
80
|
+
|
|
81
|
+
item.deactivate();
|
|
82
|
+
item.unmount();
|
|
83
|
+
|
|
84
|
+
return this.insertItem(anchor, refItem, key, value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private insertItem(anchor: Node, refItem: ReactiveItem<T, N> | null, key: K, value: T) {
|
|
88
|
+
const newNode = this.buildFn(key, value);
|
|
89
|
+
const item = new ReactiveItem(anchor, value, newNode);
|
|
90
|
+
|
|
91
|
+
item.mount(refItem);
|
|
92
|
+
item.activate(this.generationId);
|
|
93
|
+
this.items.set(key, item);
|
|
94
|
+
|
|
95
|
+
return item;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private removeStaleItems() {
|
|
99
|
+
for (const [key, item] of this.items.entries()) {
|
|
100
|
+
if (item.generationId === this.generationId) continue;
|
|
101
|
+
item.deactivate();
|
|
102
|
+
item.unmount();
|
|
103
|
+
this.items.delete(key);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ReactiveNode } from "../reactive";
|
|
2
|
+
|
|
3
|
+
export class ReactiveItem<T, N extends ReactiveNode<Node>> {
|
|
4
|
+
public generationId?: number;
|
|
5
|
+
|
|
6
|
+
constructor(
|
|
7
|
+
public anchor: Node,
|
|
8
|
+
public value: T,
|
|
9
|
+
public node: N
|
|
10
|
+
) { }
|
|
11
|
+
|
|
12
|
+
mount(refItem: ReactiveItem<T, N> | null) {
|
|
13
|
+
const parentNode = this.anchor.parentNode;
|
|
14
|
+
const insertBefore = refItem?.node.nextSibling || null;
|
|
15
|
+
|
|
16
|
+
if (parentNode === null) return;
|
|
17
|
+
if (this.node.parentNode === null) this.node.mount(parentNode);
|
|
18
|
+
|
|
19
|
+
parentNode.insertBefore(this.node, insertBefore);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
activate(generationId: number): void {
|
|
23
|
+
if (this.generationId === undefined)
|
|
24
|
+
this.node.activate();
|
|
25
|
+
this.generationId = generationId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
deactivate(): void {
|
|
29
|
+
this.node.deactivate();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
unmount(): void {
|
|
33
|
+
this.node.unmount();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { mapObservable, type Observable } from "../observables";
|
|
2
|
+
import { toReactiveNode, type ReactiveNode } from "./reactive";
|
|
3
|
+
import {
|
|
4
|
+
ReactiveItemCollection,
|
|
5
|
+
type BuildFn,
|
|
6
|
+
type Collection,
|
|
7
|
+
type Key,
|
|
8
|
+
type KeyFn
|
|
9
|
+
} from "./iterable/collection";
|
|
10
|
+
|
|
11
|
+
type Source<T> = Collection<T> | Event<T>;
|
|
12
|
+
|
|
13
|
+
export type Event<T> =
|
|
14
|
+
{ type: "replace", items: Collection<T> } |
|
|
15
|
+
{ type: "remove", items: Collection<T> } |
|
|
16
|
+
{ type: "append", items: Collection<T> } |
|
|
17
|
+
{ type: "replaceKeys", items: Collection<T> };
|
|
18
|
+
|
|
19
|
+
export const iterable = <K extends Key, T, N extends ReactiveNode<Node>>(
|
|
20
|
+
{ it$, buildFn, keyFn }: {
|
|
21
|
+
it$: Observable<Source<T>>,
|
|
22
|
+
buildFn: BuildFn<N, T>,
|
|
23
|
+
keyFn: KeyFn<K, T>
|
|
24
|
+
}
|
|
25
|
+
) => new Iterable<K, T, N>(
|
|
26
|
+
it$,
|
|
27
|
+
buildFn,
|
|
28
|
+
keyFn
|
|
29
|
+
).toReactiveNode();
|
|
30
|
+
|
|
31
|
+
class Iterable<K extends Key, T, N extends ReactiveNode<Node>> {
|
|
32
|
+
private readonly id = Symbol('Iterable');
|
|
33
|
+
private it$: Observable<Event<T>>;
|
|
34
|
+
private items: ReactiveItemCollection<K, T, N>;
|
|
35
|
+
|
|
36
|
+
constructor(
|
|
37
|
+
it$: Observable<Source<T>>,
|
|
38
|
+
buildFn: BuildFn<N, T>,
|
|
39
|
+
keyFn: KeyFn<K, T>
|
|
40
|
+
) {
|
|
41
|
+
this.it$ = this.toEventObservable(it$);
|
|
42
|
+
this.items = new ReactiveItemCollection(keyFn, buildFn);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
toReactiveNode() {
|
|
46
|
+
const anchor = document.createComment('Iterable');
|
|
47
|
+
const updateFn = (event: Event<T>) => {
|
|
48
|
+
switch (event.type) {
|
|
49
|
+
case "replace":
|
|
50
|
+
return this.items.replace(anchor, event.items);
|
|
51
|
+
case "replaceKeys":
|
|
52
|
+
return this.items.replaceKeys(anchor, event.items);
|
|
53
|
+
case "append":
|
|
54
|
+
return this.items.append(anchor, event.items);
|
|
55
|
+
case "remove":
|
|
56
|
+
return this.items.remove(event.items);
|
|
57
|
+
default:
|
|
58
|
+
return console.warn('Unsupported event type', event);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return toReactiveNode(anchor, [{
|
|
63
|
+
mount: (parentNode: Node) => parentNode.appendChild(anchor),
|
|
64
|
+
activate: () => this.it$.subscribeInit(this.id, updateFn),
|
|
65
|
+
deactivate: () => {
|
|
66
|
+
this.it$.unsubscribe(this.id);
|
|
67
|
+
this.items.deactivate();
|
|
68
|
+
},
|
|
69
|
+
unmount: () => {
|
|
70
|
+
this.items.unmount();
|
|
71
|
+
anchor.remove();
|
|
72
|
+
}
|
|
73
|
+
}]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private toEventObservable(it$: Observable<Source<T>>): Observable<Event<T>> {
|
|
77
|
+
return mapObservable((source: Source<T>) => {
|
|
78
|
+
if (source instanceof Array || source instanceof Map) {
|
|
79
|
+
return { type: "replace", items: source };
|
|
80
|
+
}
|
|
81
|
+
return source;
|
|
82
|
+
}, it$)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { buildLifecycleHooks, type Lifecycle } from '../lifecycle';
|
|
2
|
+
import type { Observable } from '../observables';
|
|
3
|
+
|
|
4
|
+
export type ReactiveNode<T extends Node> = Lifecycle & T & ReactiveNodeSetters<T>;
|
|
5
|
+
|
|
6
|
+
export interface ReactiveNodeSetters<T extends Node> {
|
|
7
|
+
clk: <R extends ReactiveNode<T>>(cb: EventListenerOrEventListenerObject) => R;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type TagReactiveNode<K extends keyof HTMLElementTagNameMap> =
|
|
11
|
+
ReactiveNode<HTMLElementTagNameMap[K]> &
|
|
12
|
+
TagReactiveNodeSetters<K>;
|
|
13
|
+
|
|
14
|
+
export interface TagReactiveNodeSetters<K extends keyof HTMLElementTagNameMap> {
|
|
15
|
+
att: (name: string, value: string) => TagReactiveNode<K>;
|
|
16
|
+
att$: (name: string, value: Observable<string>) => TagReactiveNode<K>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const toTagReactiveNode = <K extends keyof HTMLElementTagNameMap>(
|
|
20
|
+
node: HTMLElementTagNameMap[K],
|
|
21
|
+
handlers: Lifecycle[]
|
|
22
|
+
): TagReactiveNode<K> => {
|
|
23
|
+
const internalHandlers = [...handlers];
|
|
24
|
+
const tagNodeSetters = buildTagNodeSetters<K>(internalHandlers);
|
|
25
|
+
const nodeSetters = buildNodeSetters(internalHandlers);
|
|
26
|
+
const hooks = buildLifecycleHooks(internalHandlers);
|
|
27
|
+
|
|
28
|
+
return Object.assign(node, nodeSetters, tagNodeSetters, hooks);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const toReactiveNode = <T extends Node>(
|
|
32
|
+
node: T,
|
|
33
|
+
handlers: Lifecycle[]
|
|
34
|
+
): ReactiveNode<T> => {
|
|
35
|
+
const internalHandlers = [...handlers];
|
|
36
|
+
const nodeSetters = buildNodeSetters(internalHandlers);
|
|
37
|
+
const hooks = buildLifecycleHooks(internalHandlers);
|
|
38
|
+
|
|
39
|
+
return Object.assign(node, nodeSetters, hooks);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const reactiveTextNode = (text: string) => {
|
|
43
|
+
const textNode = document.createTextNode(text);
|
|
44
|
+
const hooks = [{
|
|
45
|
+
mount: (parentNode: Node) => parentNode.appendChild(textNode),
|
|
46
|
+
activate: () => undefined,
|
|
47
|
+
deactivate: () => undefined,
|
|
48
|
+
unmount: () => textNode.remove()
|
|
49
|
+
}];
|
|
50
|
+
|
|
51
|
+
return toReactiveNode(textNode, hooks);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const buildTagNodeSetters = <K extends keyof HTMLElementTagNameMap>(
|
|
55
|
+
handlers: Lifecycle[]
|
|
56
|
+
) => ({
|
|
57
|
+
att: function (this: TagReactiveNode<K>, name: string, value: string) {
|
|
58
|
+
this.setAttribute(name, value);
|
|
59
|
+
return this;
|
|
60
|
+
},
|
|
61
|
+
att$: function (
|
|
62
|
+
this: TagReactiveNode<K>,
|
|
63
|
+
name: string,
|
|
64
|
+
value$: Observable<string>
|
|
65
|
+
) {
|
|
66
|
+
const subscriberId = Symbol(`Attribute: ${name}`)
|
|
67
|
+
|
|
68
|
+
handlers.push({
|
|
69
|
+
mount: (_: Node) => undefined,
|
|
70
|
+
activate: () => value$.subscribeInit(
|
|
71
|
+
subscriberId,
|
|
72
|
+
(value: string) => this.setAttribute(name, value)),
|
|
73
|
+
deactivate: () => value$.unsubscribe(subscriberId),
|
|
74
|
+
unmount: () => undefined
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return this;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const buildNodeSetters = <T extends Node>(
|
|
82
|
+
handlers: Lifecycle[]
|
|
83
|
+
) => ({
|
|
84
|
+
clk: function <R extends ReactiveNode<T>>(
|
|
85
|
+
this: R,
|
|
86
|
+
callback: EventListenerOrEventListenerObject
|
|
87
|
+
) {
|
|
88
|
+
handlers.push({
|
|
89
|
+
mount: (_: Node) => undefined,
|
|
90
|
+
activate: () => this.addEventListener('click', callback),
|
|
91
|
+
deactivate: () => this.removeEventListener('click', callback),
|
|
92
|
+
unmount: () => undefined
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return this;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Observable } from "../observables";
|
|
2
|
+
import { toReactiveNode } from "./reactive";
|
|
3
|
+
|
|
4
|
+
type PartialTextNode = {
|
|
5
|
+
staticNodes: Text[],
|
|
6
|
+
dynamicNode: {
|
|
7
|
+
node: Text,
|
|
8
|
+
observerId: symbol,
|
|
9
|
+
observable: Observable<string>
|
|
10
|
+
} | undefined
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type Hole = Observable<string> | string;
|
|
14
|
+
|
|
15
|
+
export const template = (
|
|
16
|
+
strings: TemplateStringsArray,
|
|
17
|
+
...holes: Hole[]
|
|
18
|
+
) => new Template(strings, holes).toReactiveNode();
|
|
19
|
+
|
|
20
|
+
class Template {
|
|
21
|
+
constructor(
|
|
22
|
+
private strings: TemplateStringsArray,
|
|
23
|
+
private holes: Hole[]
|
|
24
|
+
) { }
|
|
25
|
+
|
|
26
|
+
toReactiveNode() {
|
|
27
|
+
const nodes = this.buildNodes();
|
|
28
|
+
const commentNode = document.createComment('Template');
|
|
29
|
+
|
|
30
|
+
return toReactiveNode(commentNode, [{
|
|
31
|
+
mount: (parentNode: Node) => this.appendNodes(parentNode, nodes),
|
|
32
|
+
activate: () => {
|
|
33
|
+
for (const node of nodes) this.attachObservable(node);
|
|
34
|
+
},
|
|
35
|
+
deactivate: () => {
|
|
36
|
+
for (const node of nodes) this.detachObservable(node);
|
|
37
|
+
},
|
|
38
|
+
unmount: () => this.removeNodes(nodes),
|
|
39
|
+
}]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private buildNodes() {
|
|
43
|
+
return this.strings.map((staticPart, i) => {
|
|
44
|
+
const hole = this.holes[i];
|
|
45
|
+
const partialTextNode: PartialTextNode = {
|
|
46
|
+
staticNodes: [document.createTextNode(staticPart)],
|
|
47
|
+
dynamicNode: undefined
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (typeof hole === 'string') {
|
|
51
|
+
partialTextNode.staticNodes.push(document.createTextNode(hole));
|
|
52
|
+
} else if (typeof hole === 'object' && hole !== null) {
|
|
53
|
+
partialTextNode.dynamicNode = {
|
|
54
|
+
node: document.createTextNode(''),
|
|
55
|
+
observerId: Symbol(`Template${i}`),
|
|
56
|
+
observable: hole
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return partialTextNode;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private appendNodes(parentNode: Node, nodes: PartialTextNode[]) {
|
|
65
|
+
for (const { staticNodes, dynamicNode } of nodes) {
|
|
66
|
+
for (const staticNode of staticNodes)
|
|
67
|
+
parentNode.appendChild(staticNode);
|
|
68
|
+
if (dynamicNode !== undefined)
|
|
69
|
+
parentNode.appendChild(dynamicNode.node);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
private attachObservable(partialTextNode: PartialTextNode) {
|
|
74
|
+
if (partialTextNode === undefined) return;
|
|
75
|
+
if (partialTextNode.dynamicNode === undefined) return;
|
|
76
|
+
|
|
77
|
+
const { node, observerId, observable } = partialTextNode.dynamicNode;
|
|
78
|
+
|
|
79
|
+
observable.subscribeInit(observerId, (value) => node.data = value);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
private removeNodes(nodes: PartialTextNode[]) {
|
|
83
|
+
for (const { staticNodes, dynamicNode } of nodes) {
|
|
84
|
+
for (const staticNode of staticNodes)
|
|
85
|
+
staticNode.remove();
|
|
86
|
+
if (dynamicNode !== undefined) dynamicNode.node.remove();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
private detachObservable(node: PartialTextNode) {
|
|
91
|
+
if (node.dynamicNode === undefined) return;
|
|
92
|
+
const { observerId, observable } = node.dynamicNode;
|
|
93
|
+
observable.unsubscribe(observerId);
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Observable, Observer } from ".";
|
|
2
|
+
|
|
3
|
+
export const dedupObservable = <T>(
|
|
4
|
+
innerObservable: Observable<T>,
|
|
5
|
+
compareEqualFn: CompareEqualFn<T> = (a, b) => a == b,
|
|
6
|
+
cloneFn: CloneFn<T> = (a) => a
|
|
7
|
+
) => new DedupObservable(innerObservable, compareEqualFn, cloneFn);
|
|
8
|
+
|
|
9
|
+
type CompareEqualFn<T> = (a: T, b: T) => boolean;
|
|
10
|
+
type CloneFn<T> = (a: T) => T;
|
|
11
|
+
|
|
12
|
+
class DedupObservable<T> implements Observable<T> {
|
|
13
|
+
private id = Symbol('DedupObservable');
|
|
14
|
+
private currentValue: T | undefined = undefined;
|
|
15
|
+
private isInitialized = false;
|
|
16
|
+
private observers = new Map();
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
private innerObservable: Observable<T>,
|
|
20
|
+
private compareEqualFn: CompareEqualFn<T>,
|
|
21
|
+
private cloneFn: CloneFn<T>
|
|
22
|
+
) { }
|
|
23
|
+
|
|
24
|
+
unsubscribeAll() {
|
|
25
|
+
this.observers.clear();
|
|
26
|
+
this.innerUnubscribe();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
unsubscribe(id: symbol) {
|
|
30
|
+
this.observers.delete(id);
|
|
31
|
+
this.innerUnubscribe();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
subscribe(id: symbol, observer: Observer<T>) {
|
|
35
|
+
this.observers.set(id, observer);
|
|
36
|
+
this.innerSubscribe();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
subscribeInit(id: symbol, observer: Observer<T>) {
|
|
40
|
+
this.subscribe(id, observer);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private innerSubscribe() {
|
|
44
|
+
if (this.observers.size !== 1) return;
|
|
45
|
+
this.innerObservable.subscribeInit(
|
|
46
|
+
this.id, this.updateValue.bind(this));
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
private updateValue(value: T) {
|
|
50
|
+
if (this.isInitialized && this.compareEqualFn(this.currentValue as T, value))
|
|
51
|
+
return;
|
|
52
|
+
|
|
53
|
+
this.currentValue = this.cloneFn(value), this.isInitialized = true;
|
|
54
|
+
|
|
55
|
+
for (const observer of this.observers.values())
|
|
56
|
+
observer(this.currentValue);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private innerUnubscribe() {
|
|
60
|
+
if (this.observers.size !== 0) return;
|
|
61
|
+
this.currentValue = undefined;
|
|
62
|
+
this.isInitialized = false;
|
|
63
|
+
this.innerObservable.unsubscribe(this.id);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type Observer<T> = (value: T) => void;
|
|
2
|
+
export type UpdateFn<T> = (value: T) => T;
|
|
3
|
+
|
|
4
|
+
export interface Observable<T> {
|
|
5
|
+
unsubscribeAll(): void;
|
|
6
|
+
unsubscribe(id: symbol): void;
|
|
7
|
+
subscribe(id: symbol, observer: Observer<T>): void;
|
|
8
|
+
subscribeInit(id: symbol, observer: Observer<T>): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Updatable<T> {
|
|
12
|
+
update(updateFn: UpdateFn<T>): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export * from "./value.observable";
|
|
16
|
+
export * from "./map.observable";
|
|
17
|
+
export * from "./dedup.observable";
|
|
18
|
+
export * from "./scoped.observable";
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Observable, Observer } from ".";
|
|
2
|
+
|
|
3
|
+
type ObservableValue<O extends Observable<any>> =
|
|
4
|
+
O extends Observable<infer T> ? T : never;
|
|
5
|
+
|
|
6
|
+
type Values<
|
|
7
|
+
Observables extends readonly Observable<any>[]
|
|
8
|
+
> = { [K in keyof Observables]: ObservableValue<Observables[K]> };
|
|
9
|
+
|
|
10
|
+
export const mapObservable = <
|
|
11
|
+
Observables extends readonly Observable<any>[],
|
|
12
|
+
R
|
|
13
|
+
>(
|
|
14
|
+
mapFn: (...values: Values<Observables>) => R,
|
|
15
|
+
...observables: Observables
|
|
16
|
+
): MapObservable<Observables, R> =>
|
|
17
|
+
new MapObservable(mapFn, observables);
|
|
18
|
+
|
|
19
|
+
class MapObservable<Observables extends readonly Observable<any>[], R> implements Observable<R> {
|
|
20
|
+
private observers = new Map<symbol, Observer<R>>();
|
|
21
|
+
private initializedIndices = new Set<keyof Observables>();
|
|
22
|
+
|
|
23
|
+
private ids!: { [_K in keyof Observables]: symbol };
|
|
24
|
+
private currentValues!: Partial<Values<Observables>>;
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
private mapFn: (...values: Values<Observables>) => R,
|
|
28
|
+
private observables: Observables
|
|
29
|
+
) {
|
|
30
|
+
this.ids = observables.map((_) => Symbol(`MapObservable`)) as
|
|
31
|
+
{ [_K in keyof Observables]: symbol };
|
|
32
|
+
this.currentValues = [] as Partial<Values<Observables>>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
unsubscribeAll() {
|
|
36
|
+
this.observers.clear();
|
|
37
|
+
this.innerUnubscribe();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
unsubscribe(id: symbol) {
|
|
41
|
+
this.observers.delete(id);
|
|
42
|
+
this.innerUnubscribe();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
subscribe(id: symbol, observer: Observer<R>) {
|
|
46
|
+
this.observers.set(id, observer);
|
|
47
|
+
this.innerSubscribe();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
subscribeInit(id: symbol, observer: Observer<R>) {
|
|
51
|
+
this.subscribe(id, observer);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private notifyObservers(i: keyof Observables) {
|
|
55
|
+
return (newValue: Values<Observables>[keyof Observables]) => {
|
|
56
|
+
this.currentValues[i] = newValue;
|
|
57
|
+
this.initializedIndices.add(i);
|
|
58
|
+
|
|
59
|
+
if (this.initializedIndices.size === this.currentValues.length)
|
|
60
|
+
for (const observer of this.observers.values())
|
|
61
|
+
observer(this.mapFn(...this.currentValues as Values<Observables>));
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private innerSubscribe() {
|
|
66
|
+
if (this.observers.size !== 1) return;
|
|
67
|
+
for (const [i, observable] of this.observables.entries()) {
|
|
68
|
+
observable.subscribeInit(
|
|
69
|
+
this.ids[i as keyof Observables],
|
|
70
|
+
this.notifyObservers(i));
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
private innerUnubscribe() {
|
|
75
|
+
if (this.observers.size !== 0) return;
|
|
76
|
+
for (const [i, observable] of this.observables.entries()) {
|
|
77
|
+
observable.unsubscribe(this.ids[i as keyof Observables]);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Observable, Observer, Updatable, UpdateFn } from ".";
|
|
2
|
+
|
|
3
|
+
export const scopedObservable = <T extends Observable<any>>(
|
|
4
|
+
innerObservable: T
|
|
5
|
+
) => new ScopedObservable<T>(innerObservable);
|
|
6
|
+
|
|
7
|
+
type Value<O extends Observable<any>> =
|
|
8
|
+
O extends Observable<infer T> ? T : never;
|
|
9
|
+
|
|
10
|
+
export class ScopedObservable<T extends Observable<any>> implements Observable<Value<T>> {
|
|
11
|
+
private aliases = new Map<symbol, symbol>();
|
|
12
|
+
|
|
13
|
+
constructor(
|
|
14
|
+
private innerObservable: T
|
|
15
|
+
) { }
|
|
16
|
+
|
|
17
|
+
unsubscribeAll() {
|
|
18
|
+
for (const alias of this.aliases.values())
|
|
19
|
+
this.innerObservable.unsubscribe(alias);
|
|
20
|
+
this.aliases.clear();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
unsubscribe(id: symbol) {
|
|
24
|
+
const alias = this.aliases.get(id);
|
|
25
|
+
if (alias === undefined) return;
|
|
26
|
+
this.aliases.delete(id);
|
|
27
|
+
this.innerObservable.unsubscribe(alias);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
subscribe(id: symbol, observer: Observer<Value<T>>) {
|
|
31
|
+
const alias = Symbol('ScopedObservable');
|
|
32
|
+
this.aliases.set(id, alias);
|
|
33
|
+
this.innerObservable.subscribe(alias, observer);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
subscribeInit(id: symbol, observer: Observer<Value<T>>) {
|
|
37
|
+
const alias = Symbol('ScopedObservable');
|
|
38
|
+
this.aliases.set(id, alias);
|
|
39
|
+
this.innerObservable.subscribeInit(alias, observer);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
update(
|
|
43
|
+
this: ScopedObservable<Updatable<Value<T>> & Observable<Value<T>>>,
|
|
44
|
+
updateFn: UpdateFn<Value<T>>
|
|
45
|
+
): void {
|
|
46
|
+
if ('update' in this.innerObservable)
|
|
47
|
+
(this.innerObservable as Updatable<Value<T>>).update(updateFn);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Observable, Observer, Updatable, UpdateFn } from ".";
|
|
2
|
+
import { dedupMicrotaskRunner, type TaskRunner } from "../task";
|
|
3
|
+
|
|
4
|
+
export const observable = <T>(
|
|
5
|
+
value: T,
|
|
6
|
+
opts: { microtaskRunner: TaskRunner } = { microtaskRunner: dedupMicrotaskRunner }
|
|
7
|
+
): ValueObservable<T> => new ValueObservable(value, opts);
|
|
8
|
+
|
|
9
|
+
class ValueObservable<T> implements Observable<T>, Updatable<T> {
|
|
10
|
+
private readonly observers = new Map<symbol, Observer<T>>();
|
|
11
|
+
|
|
12
|
+
constructor(
|
|
13
|
+
private value: T,
|
|
14
|
+
private opts: { microtaskRunner: TaskRunner }
|
|
15
|
+
) { }
|
|
16
|
+
|
|
17
|
+
unsubscribeAll() {
|
|
18
|
+
this.observers.clear();
|
|
19
|
+
}
|
|
20
|
+
unsubscribe(id: symbol) {
|
|
21
|
+
this.observers.delete(id);
|
|
22
|
+
}
|
|
23
|
+
subscribe(id: symbol, observer: Observer<T>) {
|
|
24
|
+
if (this.observers.has(id))
|
|
25
|
+
console.warn("Duplicate observer id", id);
|
|
26
|
+
this.observers.set(id, observer);
|
|
27
|
+
}
|
|
28
|
+
subscribeInit(id: symbol, observer: Observer<T>) {
|
|
29
|
+
this.subscribe(id, observer);
|
|
30
|
+
this.notify(id, observer);
|
|
31
|
+
}
|
|
32
|
+
update(updateFn: UpdateFn<T>) {
|
|
33
|
+
this.value = updateFn(this.value);
|
|
34
|
+
this.opts.microtaskRunner(this.notifyAll.bind(this));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private notify(id: symbol, observer: Observer<T>) {
|
|
38
|
+
try {
|
|
39
|
+
if (this.observers.get(id) === observer) observer(this.value);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
console.error(e);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
private notifyAll() {
|
|
46
|
+
for (const [id, observer] of this.observers.entries())
|
|
47
|
+
this.notify(id, observer);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|