bunja 0.1.0 → 2.0.0-alpha.1
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/README.md +53 -40
- package/bunja.ts +302 -180
- package/compat/bunja-v1.ts +47 -0
- package/deno.json +1 -1
- package/deno.lock +212 -83
- package/dist/bunja-bUA1rGXy.cjs +305 -0
- package/dist/bunja-wcx846sL.js +274 -0
- package/dist/bunja.cjs +1 -1
- package/dist/bunja.d.cts +51 -40
- package/dist/bunja.d.ts +51 -40
- package/dist/bunja.js +1 -1
- package/dist/react.cjs +7 -7
- package/dist/react.d.cts +5 -5
- package/dist/react.d.ts +5 -5
- package/dist/react.js +7 -7
- package/package.json +4 -4
- package/presentations/2024-11-28-en.pdf +0 -0
- package/presentations/2024-11-28.pdf +0 -0
- package/presentations/README.md +9 -0
- package/react.ts +24 -17
- package/test.ts +227 -0
- package/dist/bunja-3B0nQ1vI.js +0 -155
- package/dist/bunja-QjknYXY-.cjs +0 -186
package/README.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
> [!WARNING]
|
|
2
|
+
> You are viewing the `v2` branch.
|
|
3
|
+
>
|
|
4
|
+
> The current stable version is `1.x.x`.\
|
|
5
|
+
> If you want to view the code for that version,
|
|
6
|
+
> please [switch to the `v1` branch](https://github.com/disjukr/bunja/tree/v1).
|
|
7
|
+
|
|
1
8
|
# Bunja
|
|
2
9
|
|
|
3
10
|
Bunja is lightweight State Lifetime Manager.\
|
|
@@ -33,21 +40,21 @@ You can use `bunja` to define a state with a finite lifetime and use the `useBun
|
|
|
33
40
|
You can define a bunja using the `bunja` function. When you access the defined bunja with the `useBunja` hook, a bunja instance is created.\
|
|
34
41
|
If all components in the render tree that refer to the bunja disappear, the bunja instance is automatically destroyed.
|
|
35
42
|
|
|
36
|
-
If you want to trigger effects when the lifetime of a bunja starts and ends, you can use the `bunja.effect`
|
|
43
|
+
If you want to trigger effects when the lifetime of a bunja starts and ends, you can use the `bunja.effect` function.
|
|
37
44
|
|
|
38
45
|
```ts
|
|
39
46
|
import { bunja } from "bunja";
|
|
40
47
|
import { useBunja } from "bunja/react";
|
|
41
48
|
|
|
42
|
-
const countBunja = bunja(
|
|
49
|
+
const countBunja = bunja(() => {
|
|
43
50
|
const countAtom = atom(0);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
};
|
|
51
|
+
|
|
52
|
+
bunja.effect(() => {
|
|
53
|
+
console.log("mounted");
|
|
54
|
+
return () => console.log("unmounted");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return { countAtom };
|
|
51
58
|
});
|
|
52
59
|
|
|
53
60
|
function MyComponent() {
|
|
@@ -67,7 +74,7 @@ In such a case, you can write the following code.
|
|
|
67
74
|
|
|
68
75
|
```ts
|
|
69
76
|
// To simplify the example, code for buffering and reconnection has been omitted.
|
|
70
|
-
const websocketBunja = bunja(
|
|
77
|
+
const websocketBunja = bunja(() => {
|
|
71
78
|
let socket;
|
|
72
79
|
const send = (message) => socket.send(JSON.stringify(message));
|
|
73
80
|
|
|
@@ -77,35 +84,35 @@ const websocketBunja = bunja([], () => {
|
|
|
77
84
|
return () => emitter.off("message", handler);
|
|
78
85
|
};
|
|
79
86
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
},
|
|
88
|
-
};
|
|
87
|
+
bunja.effect(() => {
|
|
88
|
+
socket = new WebSocket("...");
|
|
89
|
+
socket.onmessage = (e) => emitter.emit("message", JSON.parse(e.data));
|
|
90
|
+
return () => socket.close();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return { send, on };
|
|
89
94
|
});
|
|
90
95
|
|
|
91
|
-
const resourceFooBunja = bunja(
|
|
96
|
+
const resourceFooBunja = bunja(() => {
|
|
97
|
+
const { send, on } = bunja.use(websocketBunja);
|
|
92
98
|
const resourceFooAtom = atom();
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
};
|
|
99
|
+
|
|
100
|
+
bunja.effect(() => {
|
|
101
|
+
const off = on((message) => {
|
|
102
|
+
if (message.type === "foo") store.set(resourceAtom, message.value);
|
|
103
|
+
});
|
|
104
|
+
send("subscribe-foo");
|
|
105
|
+
return () => {
|
|
106
|
+
send("unsubscribe-foo");
|
|
107
|
+
off();
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return { resourceFooAtom };
|
|
106
112
|
});
|
|
107
113
|
|
|
108
|
-
const resourceBarBunja = bunja(
|
|
114
|
+
const resourceBarBunja = bunja(() => {
|
|
115
|
+
const { send, on } = bunja.use(websocketBunja);
|
|
109
116
|
const resourceBarAtom = atom();
|
|
110
117
|
// ...
|
|
111
118
|
});
|
|
@@ -144,11 +151,14 @@ import { bunja, createScope } from "bunja";
|
|
|
144
151
|
|
|
145
152
|
const UrlScope = createScope();
|
|
146
153
|
|
|
147
|
-
const fetchBunja = bunja(
|
|
154
|
+
const fetchBunja = bunja(() => {
|
|
155
|
+
const url = bunja.use(UrlScope);
|
|
156
|
+
|
|
148
157
|
const queryAtom = atomWithQuery((get) => ({
|
|
149
158
|
queryKey: [url],
|
|
150
159
|
queryFn: async () => (await fetch(url)).json(),
|
|
151
160
|
}));
|
|
161
|
+
|
|
152
162
|
return { queryAtom };
|
|
153
163
|
});
|
|
154
164
|
```
|
|
@@ -168,23 +178,26 @@ const UrlContext = createContext("https://example.com/");
|
|
|
168
178
|
const UrlScope = createScope();
|
|
169
179
|
bindScope(UrlScope, UrlContext);
|
|
170
180
|
|
|
171
|
-
const fetchBunja = bunja(
|
|
181
|
+
const fetchBunja = bunja(() => {
|
|
182
|
+
const url = bunja.use(UrlScope);
|
|
183
|
+
|
|
172
184
|
const queryAtom = atomWithQuery((get) => ({
|
|
173
185
|
queryKey: [url],
|
|
174
186
|
queryFn: async () => (await fetch(url)).json(),
|
|
175
187
|
}));
|
|
188
|
+
|
|
176
189
|
return { queryAtom };
|
|
177
190
|
});
|
|
178
191
|
|
|
179
192
|
function ParentComponent() {
|
|
180
193
|
return (
|
|
181
194
|
<>
|
|
182
|
-
<UrlContext
|
|
195
|
+
<UrlContext value="https://example.com/foo">
|
|
183
196
|
<ChildComponent />
|
|
184
|
-
</UrlContext
|
|
185
|
-
<UrlContext
|
|
197
|
+
</UrlContext>
|
|
198
|
+
<UrlContext value="https://example.com/bar">
|
|
186
199
|
<ChildComponent />
|
|
187
|
-
</UrlContext
|
|
200
|
+
</UrlContext>
|
|
188
201
|
</>
|
|
189
202
|
);
|
|
190
203
|
}
|
package/bunja.ts
CHANGED
|
@@ -1,231 +1,353 @@
|
|
|
1
|
-
export
|
|
1
|
+
export interface BunjaFn {
|
|
2
|
+
<T>(init: () => T): Bunja<T>;
|
|
3
|
+
use: BunjaUseFn;
|
|
4
|
+
effect: BunjaEffectFn;
|
|
5
|
+
}
|
|
6
|
+
export const bunja: BunjaFn = bunjaFn;
|
|
7
|
+
function bunjaFn<T>(init: () => T): Bunja<T> {
|
|
8
|
+
return new Bunja(init);
|
|
9
|
+
}
|
|
10
|
+
bunjaFn.use = invalidUse as BunjaUseFn;
|
|
11
|
+
bunjaFn.effect = invalidEffect as BunjaEffectFn;
|
|
2
12
|
|
|
3
|
-
|
|
4
|
-
type
|
|
13
|
+
export type BunjaUseFn = <T>(dep: Dep<T>) => T;
|
|
14
|
+
export type BunjaEffectFn = (callback: BunjaEffectCallback) => void;
|
|
15
|
+
export type BunjaEffectCallback = () => (() => void) | void;
|
|
5
16
|
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
public readonly id: number;
|
|
9
|
-
public debugLabel: string = "";
|
|
10
|
-
constructor(
|
|
11
|
-
public deps: Dep<any>[], // one depth dependencies
|
|
12
|
-
public parents: Bunja<any>[], // one depth parents
|
|
13
|
-
public relatedBunjas: Bunja<any>[], // toposorted parents without self
|
|
14
|
-
public relatedScopes: Scope<any>[], // deduped
|
|
15
|
-
public init: (...args: any[]) => T & BunjaValue,
|
|
16
|
-
) {
|
|
17
|
-
this.id = Bunja.bunjas.length;
|
|
18
|
-
Bunja.bunjas.push(this);
|
|
19
|
-
}
|
|
20
|
-
static readonly effect: BunjaEffectSymbol = bunjaEffectSymbol;
|
|
21
|
-
toString(): string {
|
|
22
|
-
const { id, debugLabel } = this;
|
|
23
|
-
return `[Bunja:${id}${debugLabel && ` - ${debugLabel}`}]`;
|
|
24
|
-
}
|
|
17
|
+
export function createScope<T>(hash?: HashFn<T>): Scope<T> {
|
|
18
|
+
return new Scope(hash);
|
|
25
19
|
}
|
|
26
20
|
|
|
27
|
-
export
|
|
28
|
-
|
|
29
|
-
public readonly id: number;
|
|
30
|
-
public debugLabel: string = "";
|
|
31
|
-
constructor() {
|
|
32
|
-
this.id = Scope.scopes.length;
|
|
33
|
-
Scope.scopes.push(this);
|
|
34
|
-
}
|
|
35
|
-
toString(): string {
|
|
36
|
-
const { id, debugLabel } = this;
|
|
37
|
-
return `[Scope:${id}${debugLabel && ` - ${debugLabel}`}]`;
|
|
38
|
-
}
|
|
21
|
+
export function createBunjaStore(): BunjaStore {
|
|
22
|
+
return new BunjaStore();
|
|
39
23
|
}
|
|
40
24
|
|
|
41
|
-
export type
|
|
25
|
+
export type Dep<T> = Bunja<T> | Scope<T>;
|
|
26
|
+
|
|
27
|
+
function invalidUse() {
|
|
28
|
+
throw new Error(
|
|
29
|
+
"`bunja.use` can only be used inside a bunja init function.",
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
function invalidEffect() {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"`bunja.effect` can only be used inside a bunja init function.",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface BunjaStoreGetContext {
|
|
39
|
+
bunjaInstance: BunjaInstance;
|
|
40
|
+
bunjaInstanceMap: BunjaInstanceMap;
|
|
41
|
+
scopeInstanceMap: ScopeInstanceMap;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type BunjaInstanceMap = Map<Bunja<unknown>, BunjaInstance>;
|
|
45
|
+
type ScopeInstanceMap = Map<Scope<unknown>, ScopeInstance>;
|
|
46
|
+
|
|
47
|
+
interface BunjaBakingContext {
|
|
48
|
+
currentBunja: Bunja<unknown>;
|
|
49
|
+
}
|
|
42
50
|
|
|
43
51
|
export class BunjaStore {
|
|
44
52
|
#bunjas: Record<string, BunjaInstance> = {};
|
|
45
|
-
#scopes: Map<Scope<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
#scopes: Map<Scope<unknown>, Map<unknown, ScopeInstance>> = new Map();
|
|
54
|
+
#bakingContext: BunjaBakingContext | undefined;
|
|
55
|
+
dispose(): void {
|
|
56
|
+
for (const instance of Object.values(this.#bunjas)) instance.dispose();
|
|
57
|
+
for (const instanceMap of Object.values(this.#scopes)) {
|
|
58
|
+
for (const instance of instanceMap.values()) instance.dispose();
|
|
59
|
+
}
|
|
60
|
+
this.#bunjas = {};
|
|
61
|
+
this.#scopes = new Map();
|
|
62
|
+
}
|
|
63
|
+
get<T>(bunja: Bunja<T>, readScope: ReadScope): BunjaStoreGetResult<T> {
|
|
64
|
+
const originalUse = bunjaFn.use;
|
|
65
|
+
try {
|
|
66
|
+
const { bunjaInstance, bunjaInstanceMap, scopeInstanceMap } = bunja.baked
|
|
67
|
+
? this.#getBaked(bunja, readScope)
|
|
68
|
+
: this.#getUnbaked(bunja, readScope);
|
|
69
|
+
return {
|
|
70
|
+
value: bunjaInstance.value as T,
|
|
71
|
+
mount: () => {
|
|
72
|
+
bunjaInstanceMap.forEach((instance) => instance.add());
|
|
73
|
+
bunjaInstance.add();
|
|
74
|
+
scopeInstanceMap.forEach((instance) => instance.add());
|
|
75
|
+
const unmount = () => {
|
|
76
|
+
setTimeout(() => {
|
|
77
|
+
bunjaInstanceMap.forEach((instance) => instance.sub());
|
|
78
|
+
bunjaInstance.sub();
|
|
79
|
+
scopeInstanceMap.forEach((instance) => instance.sub());
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
return unmount;
|
|
83
|
+
},
|
|
84
|
+
deps: Array.from(scopeInstanceMap.values()).map(({ value }) => value),
|
|
85
|
+
};
|
|
86
|
+
} finally {
|
|
87
|
+
bunjaFn.use = originalUse;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
#getBaked<T>(bunja: Bunja<T>, readScope: ReadScope): BunjaStoreGetContext {
|
|
54
91
|
const scopeInstanceMap = new Map(
|
|
55
92
|
bunja.relatedScopes.map((scope) => [
|
|
56
93
|
scope,
|
|
57
94
|
this.#getScopeInstance(scope, readScope(scope)),
|
|
58
95
|
]),
|
|
59
96
|
);
|
|
60
|
-
const
|
|
61
|
-
const { relatedBunjaInstanceMap } = bunjaInstance; // toposorted
|
|
62
|
-
return {
|
|
63
|
-
value: bunjaInstance.value as T,
|
|
64
|
-
mount() {
|
|
65
|
-
relatedBunjaInstanceMap.forEach((related) => related.add());
|
|
66
|
-
bunjaInstance.add();
|
|
67
|
-
scopeInstanceMap.forEach((scope) => scope.add());
|
|
68
|
-
return function unmount(): void {
|
|
69
|
-
// concern: reverse order?
|
|
70
|
-
relatedBunjaInstanceMap.forEach((related) => related.sub());
|
|
71
|
-
bunjaInstance.sub();
|
|
72
|
-
scopeInstanceMap.forEach((scope) => scope.sub());
|
|
73
|
-
};
|
|
74
|
-
},
|
|
75
|
-
deps: Array.from(scopeInstanceMap.values()).map(({ value }) => value),
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
#getBunjaInstance(
|
|
79
|
-
bunja: Bunja<any>,
|
|
80
|
-
scopeInstanceMap: Map<Scope<any>, ScopeInstance>,
|
|
81
|
-
): BunjaInstance {
|
|
82
|
-
const localScopeInstanceMap = new Map(
|
|
83
|
-
bunja.relatedScopes.map((scope) => [scope, scopeInstanceMap.get(scope)!]),
|
|
84
|
-
);
|
|
85
|
-
const scopeInstanceIds = Array.from(localScopeInstanceMap.values())
|
|
86
|
-
.map(({ instanceId }) => instanceId)
|
|
87
|
-
.sort((a, b) => a - b);
|
|
88
|
-
const bunjaInstanceId = `${bunja.id}:${scopeInstanceIds.join(",")}`;
|
|
89
|
-
if (this.#bunjas[bunjaInstanceId]) return this.#bunjas[bunjaInstanceId];
|
|
90
|
-
const relatedBunjaInstanceMap = new Map(
|
|
97
|
+
const bunjaInstanceMap = new Map(
|
|
91
98
|
bunja.relatedBunjas.map((relatedBunja) => [
|
|
92
99
|
relatedBunja,
|
|
93
100
|
this.#getBunjaInstance(relatedBunja, scopeInstanceMap),
|
|
94
101
|
]),
|
|
95
102
|
);
|
|
96
|
-
|
|
97
|
-
if (dep instanceof Bunja)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
bunjaFn.use = <T>(dep: Dep<T>) => {
|
|
104
|
+
if (dep instanceof Bunja) {
|
|
105
|
+
return bunjaInstanceMap.get(dep as Bunja<unknown>)!.value as T;
|
|
106
|
+
}
|
|
107
|
+
if (dep instanceof Scope) {
|
|
108
|
+
return scopeInstanceMap.get(dep as Scope<unknown>)!.value as T;
|
|
109
|
+
}
|
|
110
|
+
throw new Error("`bunja.use` can only be used with Bunja or Scope.");
|
|
111
|
+
};
|
|
112
|
+
const bunjaInstance = this.#getBunjaInstance(bunja, scopeInstanceMap);
|
|
113
|
+
return { bunjaInstance, bunjaInstanceMap, scopeInstanceMap };
|
|
114
|
+
}
|
|
115
|
+
#getUnbaked<T>(bunja: Bunja<T>, readScope: ReadScope): BunjaStoreGetContext {
|
|
116
|
+
const bunjaInstanceMap: BunjaInstanceMap = new Map();
|
|
117
|
+
const scopeInstanceMap: ScopeInstanceMap = new Map();
|
|
118
|
+
function getUse<D extends Dep<unknown>, I extends { value: unknown }>(
|
|
119
|
+
map: Map<D, I>,
|
|
120
|
+
addDep: (D: D) => void,
|
|
121
|
+
getInstance: (dep: D) => I,
|
|
122
|
+
) {
|
|
123
|
+
return ((dep) => {
|
|
124
|
+
const d = dep as D;
|
|
125
|
+
addDep(d);
|
|
126
|
+
if (map.has(d)) return map.get(d)!.value as T;
|
|
127
|
+
const instance = getInstance(d);
|
|
128
|
+
map.set(d, instance);
|
|
129
|
+
return instance.value as T;
|
|
130
|
+
}) as <T>(dep: Dep<T>) => T;
|
|
131
|
+
}
|
|
132
|
+
const useScope = getUse(
|
|
133
|
+
scopeInstanceMap,
|
|
134
|
+
(dep) => this.#bakingContext!.currentBunja.addScope(dep),
|
|
135
|
+
(dep) => this.#getScopeInstance(dep, readScope(dep)),
|
|
136
|
+
);
|
|
137
|
+
const useBunja = getUse(
|
|
138
|
+
bunjaInstanceMap,
|
|
139
|
+
(dep) => this.#bakingContext!.currentBunja.addParent(dep),
|
|
140
|
+
(dep) => {
|
|
141
|
+
if (dep.baked) {
|
|
142
|
+
for (const scope of dep.relatedScopes) useScope(scope);
|
|
143
|
+
}
|
|
144
|
+
return this.#getBunjaInstance(dep, scopeInstanceMap);
|
|
145
|
+
},
|
|
106
146
|
);
|
|
107
|
-
|
|
108
|
-
|
|
147
|
+
bunjaFn.use = <T>(dep: Dep<T>) => {
|
|
148
|
+
if (dep instanceof Bunja) return useBunja(dep) as T;
|
|
149
|
+
if (dep instanceof Scope) return useScope(dep) as T;
|
|
150
|
+
throw new Error("`bunja.use` can only be used with Bunja or Scope.");
|
|
151
|
+
};
|
|
152
|
+
try {
|
|
153
|
+
this.#bakingContext = { currentBunja: bunja };
|
|
154
|
+
const bunjaInstance = this.#getBunjaInstance(bunja, scopeInstanceMap);
|
|
155
|
+
return { bunjaInstance, bunjaInstanceMap, scopeInstanceMap };
|
|
156
|
+
} finally {
|
|
157
|
+
this.#bakingContext = undefined;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
#getBunjaInstance<T>(
|
|
161
|
+
bunja: Bunja<T>,
|
|
162
|
+
scopeInstanceMap: ScopeInstanceMap,
|
|
163
|
+
): BunjaInstance {
|
|
164
|
+
const originalEffect = bunjaFn.effect;
|
|
165
|
+
const prevBunja = this.#bakingContext?.currentBunja;
|
|
166
|
+
try {
|
|
167
|
+
const effects: BunjaEffectCallback[] = [];
|
|
168
|
+
bunjaFn.effect = (callback: BunjaEffectCallback) => {
|
|
169
|
+
effects.push(callback);
|
|
170
|
+
};
|
|
171
|
+
if (this.#bakingContext) this.#bakingContext.currentBunja = bunja;
|
|
172
|
+
if (bunja.baked) {
|
|
173
|
+
const id = bunja.calcInstanceId(scopeInstanceMap);
|
|
174
|
+
if (id in this.#bunjas) return this.#bunjas[id];
|
|
175
|
+
const bunjaInstanceValue = bunja.init();
|
|
176
|
+
return this.#createBunjaInstance(id, bunjaInstanceValue, effects);
|
|
177
|
+
} else {
|
|
178
|
+
const bunjaInstanceValue = bunja.init();
|
|
179
|
+
bunja.bake();
|
|
180
|
+
const id = bunja.calcInstanceId(scopeInstanceMap);
|
|
181
|
+
return this.#createBunjaInstance(id, bunjaInstanceValue, effects);
|
|
182
|
+
}
|
|
183
|
+
} finally {
|
|
184
|
+
bunjaFn.effect = originalEffect;
|
|
185
|
+
if (this.#bakingContext) this.#bakingContext.currentBunja = prevBunja!;
|
|
186
|
+
}
|
|
109
187
|
}
|
|
110
|
-
#getScopeInstance(scope: Scope<
|
|
111
|
-
const
|
|
188
|
+
#getScopeInstance(scope: Scope<unknown>, value: unknown): ScopeInstance {
|
|
189
|
+
const key = scope.hash(value);
|
|
190
|
+
const instanceMap = this.#scopes.get(scope) ??
|
|
112
191
|
this.#scopes.set(scope, new Map()).get(scope)!;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
ScopeInstance.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
192
|
+
return instanceMap.get(key) ??
|
|
193
|
+
instanceMap.set(
|
|
194
|
+
key,
|
|
195
|
+
new ScopeInstance(value, () => instanceMap.delete(key)),
|
|
196
|
+
).get(key)!;
|
|
197
|
+
}
|
|
198
|
+
#createBunjaInstance(
|
|
199
|
+
id: string,
|
|
200
|
+
value: unknown,
|
|
201
|
+
effects: BunjaEffectCallback[],
|
|
202
|
+
): BunjaInstance {
|
|
203
|
+
const effect = () => {
|
|
204
|
+
const cleanups = effects
|
|
205
|
+
.map((effect) => effect())
|
|
206
|
+
.filter(Boolean) as (() => void)[];
|
|
207
|
+
return () => cleanups.forEach((cleanup) => cleanup());
|
|
208
|
+
};
|
|
209
|
+
const dispose = () => delete this.#bunjas[id];
|
|
210
|
+
const bunjaInstance = new BunjaInstance(id, value, effect, dispose);
|
|
211
|
+
this.#bunjas[id] = bunjaInstance;
|
|
212
|
+
return bunjaInstance;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export type ReadScope = <T>(scope: Scope<T>) => T;
|
|
217
|
+
|
|
218
|
+
export interface BunjaStoreGetResult<T> {
|
|
219
|
+
value: T;
|
|
220
|
+
mount: () => () => void;
|
|
221
|
+
deps: unknown[];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export class Bunja<T> {
|
|
225
|
+
private static counter: number = 0;
|
|
226
|
+
readonly id: string = String(Bunja.counter++);
|
|
227
|
+
debugLabel: string = "";
|
|
228
|
+
#phase: BunjaPhase = { baked: false, parents: new Set(), scopes: new Set() };
|
|
229
|
+
constructor(public init: () => T) {}
|
|
230
|
+
get baked(): boolean {
|
|
231
|
+
return this.#phase.baked;
|
|
232
|
+
}
|
|
233
|
+
get parents(): Bunja<unknown>[] {
|
|
234
|
+
if (this.#phase.baked) return this.#phase.parents;
|
|
235
|
+
return Array.from(this.#phase.parents);
|
|
236
|
+
}
|
|
237
|
+
get relatedBunjas(): Bunja<unknown>[] {
|
|
238
|
+
if (!this.#phase.baked) throw new Error("Bunja is not baked yet.");
|
|
239
|
+
return this.#phase.relatedBunjas;
|
|
240
|
+
}
|
|
241
|
+
get relatedScopes(): Scope<unknown>[] {
|
|
242
|
+
if (!this.#phase.baked) throw new Error("Bunja is not baked yet.");
|
|
243
|
+
return this.#phase.relatedScopes;
|
|
244
|
+
}
|
|
245
|
+
addParent(bunja: Bunja<unknown>): void {
|
|
246
|
+
if (this.#phase.baked) return;
|
|
247
|
+
this.#phase.parents.add(bunja);
|
|
248
|
+
}
|
|
249
|
+
addScope(scope: Scope<unknown>): void {
|
|
250
|
+
if (this.#phase.baked) return;
|
|
251
|
+
this.#phase.scopes.add(scope);
|
|
252
|
+
}
|
|
253
|
+
bake(): void {
|
|
254
|
+
if (this.#phase.baked) throw new Error("Bunja is already baked.");
|
|
255
|
+
const scopes = this.#phase.scopes;
|
|
256
|
+
const parents = this.parents;
|
|
257
|
+
const relatedBunjas = toposort(parents);
|
|
258
|
+
const relatedScopes = Array.from(
|
|
259
|
+
new Set([
|
|
260
|
+
...relatedBunjas.flatMap((bunja) => bunja.relatedScopes),
|
|
261
|
+
...scopes,
|
|
262
|
+
]),
|
|
263
|
+
);
|
|
264
|
+
this.#phase = { baked: true, parents, relatedBunjas, relatedScopes };
|
|
265
|
+
}
|
|
266
|
+
calcInstanceId(scopeInstanceMap: Map<Scope<unknown>, ScopeInstance>): string {
|
|
267
|
+
const scopeInstanceIds = this.relatedScopes.map(
|
|
268
|
+
(scope) => scopeInstanceMap.get(scope)!.id,
|
|
123
269
|
);
|
|
270
|
+
return `${this.id}:${scopeInstanceIds.join(",")}`;
|
|
271
|
+
}
|
|
272
|
+
toString(): string {
|
|
273
|
+
const { id, debugLabel } = this;
|
|
274
|
+
return `[Bunja:${id}${debugLabel && ` - ${debugLabel}`}]`;
|
|
124
275
|
}
|
|
125
276
|
}
|
|
126
277
|
|
|
127
|
-
|
|
278
|
+
type BunjaPhase = BunjaPhaseUnbaked | BunjaPhaseBaked;
|
|
128
279
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
280
|
+
interface BunjaPhaseUnbaked {
|
|
281
|
+
readonly baked: false;
|
|
282
|
+
readonly parents: Set<Bunja<unknown>>;
|
|
283
|
+
readonly scopes: Set<Scope<unknown>>;
|
|
132
284
|
}
|
|
133
285
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const scopes = deps.filter((dep) => dep instanceof Scope) as Scope<any>[];
|
|
140
|
-
const relatedBunjas = toposort(parents);
|
|
141
|
-
const relatedScopes = Array.from(
|
|
142
|
-
new Set([...scopes, ...parents.flatMap((parent) => parent.relatedScopes)]),
|
|
143
|
-
);
|
|
144
|
-
return new Bunja(deps, parents, relatedBunjas, relatedScopes, init as any);
|
|
145
|
-
}
|
|
146
|
-
bunjaImpl.effect = Bunja.effect;
|
|
147
|
-
|
|
148
|
-
export const bunja: {
|
|
149
|
-
<T>(deps: [], init: () => T & BunjaValue): Bunja<T>;
|
|
150
|
-
<T, U>(deps: [Dep<U>], init: (u: U) => T & BunjaValue): Bunja<T>;
|
|
151
|
-
<T, U, V>(
|
|
152
|
-
deps: [Dep<U>, Dep<V>],
|
|
153
|
-
init: (u: U, v: V) => T & BunjaValue,
|
|
154
|
-
): Bunja<T>;
|
|
155
|
-
<T, U, V, W>(
|
|
156
|
-
deps: [Dep<U>, Dep<V>, Dep<W>],
|
|
157
|
-
init: (u: U, v: V, w: W) => T & BunjaValue,
|
|
158
|
-
): Bunja<T>;
|
|
159
|
-
<T, U, V, W, X>(
|
|
160
|
-
deps: [Dep<U>, Dep<V>, Dep<W>, Dep<X>],
|
|
161
|
-
init: (u: U, v: V, w: W, x: X) => T & BunjaValue,
|
|
162
|
-
): Bunja<T>;
|
|
163
|
-
<T, U, V, W, X, Y>(
|
|
164
|
-
deps: [Dep<U>, Dep<V>, Dep<W>, Dep<X>, Dep<Y>],
|
|
165
|
-
init: (u: U, v: V, w: W, x: X, y: Y) => T & BunjaValue,
|
|
166
|
-
): Bunja<T>;
|
|
167
|
-
<T, U, V, W, X, Y, Z>(
|
|
168
|
-
deps: [Dep<U>, Dep<V>, Dep<W>, Dep<X>, Dep<Y>, Dep<Z>],
|
|
169
|
-
init: (u: U, v: V, w: W, x: X, y: Y, z: Z) => T & BunjaValue,
|
|
170
|
-
): Bunja<T>;
|
|
171
|
-
readonly effect: BunjaEffectSymbol;
|
|
172
|
-
} = bunjaImpl;
|
|
173
|
-
|
|
174
|
-
export function createScope<T>(): Scope<T> {
|
|
175
|
-
return new Scope();
|
|
286
|
+
interface BunjaPhaseBaked {
|
|
287
|
+
readonly baked: true;
|
|
288
|
+
readonly parents: Bunja<unknown>[];
|
|
289
|
+
readonly relatedBunjas: Bunja<unknown>[];
|
|
290
|
+
readonly relatedScopes: Scope<unknown>[];
|
|
176
291
|
}
|
|
177
292
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
this.#disposed = true;
|
|
190
|
-
this.dispose();
|
|
191
|
-
}
|
|
192
|
-
});
|
|
293
|
+
export class Scope<T> {
|
|
294
|
+
private static counter: number = 0;
|
|
295
|
+
readonly id: string = String(Scope.counter++);
|
|
296
|
+
debugLabel: string = "";
|
|
297
|
+
constructor(public readonly hash: HashFn<T> = Scope.identity) {}
|
|
298
|
+
private static identity<T>(x: T): T {
|
|
299
|
+
return x;
|
|
300
|
+
}
|
|
301
|
+
toString(): string {
|
|
302
|
+
const { id, debugLabel } = this;
|
|
303
|
+
return `[Scope:${id}${debugLabel && ` - ${debugLabel}`}]`;
|
|
193
304
|
}
|
|
194
|
-
abstract dispose(): void;
|
|
195
305
|
}
|
|
196
306
|
|
|
307
|
+
export type HashFn<T> = (value: T) => unknown;
|
|
308
|
+
|
|
197
309
|
const noop = () => {};
|
|
310
|
+
abstract class RefCounter {
|
|
311
|
+
#count: number = 0;
|
|
312
|
+
abstract dispose(): void;
|
|
313
|
+
add(): void {
|
|
314
|
+
++this.#count;
|
|
315
|
+
}
|
|
316
|
+
sub(): void {
|
|
317
|
+
--this.#count;
|
|
318
|
+
if (this.#count < 1) {
|
|
319
|
+
this.dispose();
|
|
320
|
+
this.dispose = noop;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
198
325
|
class BunjaInstance extends RefCounter {
|
|
199
326
|
#cleanup: (() => void) | undefined;
|
|
200
|
-
#dispose: () => void;
|
|
201
327
|
constructor(
|
|
202
|
-
|
|
203
|
-
public
|
|
204
|
-
public
|
|
205
|
-
|
|
328
|
+
public readonly id: string,
|
|
329
|
+
public readonly value: unknown,
|
|
330
|
+
public readonly effect: BunjaEffectCallback,
|
|
331
|
+
private readonly _dispose: () => void,
|
|
206
332
|
) {
|
|
207
333
|
super();
|
|
208
|
-
this.#dispose = () => {
|
|
209
|
-
this.#cleanup?.();
|
|
210
|
-
dispose();
|
|
211
|
-
};
|
|
212
334
|
}
|
|
213
|
-
override
|
|
214
|
-
this.#cleanup
|
|
215
|
-
|
|
335
|
+
override dispose(): void {
|
|
336
|
+
this.#cleanup?.();
|
|
337
|
+
this._dispose();
|
|
216
338
|
}
|
|
217
|
-
|
|
218
|
-
this.#
|
|
339
|
+
override add(): void {
|
|
340
|
+
this.#cleanup ??= this.effect() ?? noop;
|
|
341
|
+
super.add();
|
|
219
342
|
}
|
|
220
343
|
}
|
|
221
344
|
|
|
222
345
|
class ScopeInstance extends RefCounter {
|
|
223
|
-
|
|
346
|
+
private static counter: number = 0;
|
|
347
|
+
readonly id: string = String(ScopeInstance.counter++);
|
|
224
348
|
constructor(
|
|
225
|
-
public
|
|
226
|
-
public
|
|
227
|
-
public scope: Scope<any>,
|
|
228
|
-
public value: any,
|
|
349
|
+
public readonly value: unknown,
|
|
350
|
+
public readonly dispose: () => void,
|
|
229
351
|
) {
|
|
230
352
|
super();
|
|
231
353
|
}
|