bunja 0.0.12 → 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.
@@ -1,4 +1,5 @@
1
1
  {
2
+ "deno.enable": true,
2
3
  "editor.formatOnSave": true,
3
- "editor.defaultFormatter": "esbenp.prettier-vscode"
4
+ "editor.defaultFormatter": "denoland.vscode-deno"
4
5
  }
package/README.md CHANGED
@@ -57,4 +57,170 @@ function MyComponent() {
57
57
  }
58
58
  ```
59
59
 
60
- TODO: context
60
+ ### Defining a Bunja that relies on other Bunja
61
+
62
+ If you want to manage a state with a broad lifetime and another state with a narrower lifetime, you can create a (narrower) bunja that depends on a (broader) bunja.
63
+ For example, you can think of a bunja that manages the WebSocket connection and disconnection, and another bunja that subscribes to a specific resource over the connected WebSocket.
64
+
65
+ In an application composed of multiple pages, you might want to subscribe to the Foo resource on page A and the Bar resource on page B, while using the same WebSocket connection regardless of which page you're on.
66
+ In such a case, you can write the following code.
67
+
68
+ ```ts
69
+ // To simplify the example, code for buffering and reconnection has been omitted.
70
+ const websocketBunja = bunja([], () => {
71
+ let socket;
72
+ const send = (message) => socket.send(JSON.stringify(message));
73
+
74
+ const emitter = new EventEmitter();
75
+ const on = (handler) => {
76
+ emitter.on("message", handler);
77
+ return () => emitter.off("message", handler);
78
+ };
79
+
80
+ return {
81
+ send,
82
+ on,
83
+ [bunja.effect]() {
84
+ socket = new WebSocket("...");
85
+ socket.onmessage = (e) => emitter.emit("message", JSON.parse(e.data));
86
+ return () => socket.close();
87
+ },
88
+ };
89
+ });
90
+
91
+ const resourceFooBunja = bunja([websocketBunja], ({ send, on }) => {
92
+ const resourceFooAtom = atom();
93
+ return {
94
+ resourceFooAtom,
95
+ [bunja.effect]() {
96
+ const off = on((message) => {
97
+ if (message.type === "foo") store.set(resourceAtom, message.value);
98
+ });
99
+ send("subscribe-foo");
100
+ return () => {
101
+ send("unsubscribe-foo");
102
+ off();
103
+ };
104
+ },
105
+ };
106
+ });
107
+
108
+ const resourceBarBunja = bunja([websocketBunja], ({ send, on }) => {
109
+ const resourceBarAtom = atom();
110
+ // ...
111
+ });
112
+
113
+ function PageA() {
114
+ const { resourceFooAtom } = useBunja(resourceFooBunja);
115
+ const resourceFoo = useAtomValue(resourceFooAtom);
116
+ // ...
117
+ }
118
+
119
+ function PageB() {
120
+ const { resourceBarAtom } = useBunja(resourceBarBunja);
121
+ const resourceBar = useAtomValue(resourceBarAtom);
122
+ // ...
123
+ }
124
+ ```
125
+
126
+ Notice that `websocketBunja` is not directly `useBunja`-ed.
127
+ When you `useBunja` either `resourceFooBunja` or `resourceBarBunja`, since they depend on `websocketBunja`,
128
+ it has the same effect as if `websocketBunja` were also `useBunja`-ed.
129
+
130
+ > [!NOTE]
131
+ > When a bunja starts, the initialization effect of the bunja with a broader lifetime is called first.\
132
+ > Similarly, when a bunja ends, the cleanup effect of the bunja with the broader lifetime is called first.\
133
+ > This behavior is aligned with how React's `useEffect` cleanup function is invoked, where the parent’s cleanup is executed before the child’s in the render tree.
134
+ >
135
+ > See: <https://github.com/facebook/react/issues/16728>
136
+
137
+ ### Dependency injection using Scope
138
+
139
+ You can use a bunja for local state management.\
140
+ When you specify a scope as a dependency of the bunja, separate bunja instances are created based on the values injected into the scope.
141
+
142
+ ```ts
143
+ import { bunja, createScope } from "bunja";
144
+
145
+ const UrlScope = createScope();
146
+
147
+ const fetchBunja = bunja([UrlScope], (url) => {
148
+ const queryAtom = atomWithQuery((get) => ({
149
+ queryKey: [url],
150
+ queryFn: async () => (await fetch(url)).json(),
151
+ }));
152
+ return { queryAtom };
153
+ });
154
+ ```
155
+
156
+ #### Injecting dependencies via React context
157
+
158
+ If you bind a scope to a React context, bunjas that depend on the scope can retrieve values from the corresponding React context.
159
+
160
+ In the example below, there are two React instances (`<ChildComponent />`) that reference the same `fetchBunja`, but since each looks at a different context value, two separate bunja instances are also created.
161
+
162
+ ```tsx
163
+ import { createContext } from "react";
164
+ import { bunja, createScope } from "bunja";
165
+ import { bindScope } from "bunja/react";
166
+
167
+ const UrlContext = createContext("https://example.com/");
168
+ const UrlScope = createScope();
169
+ bindScope(UrlScope, UrlContext);
170
+
171
+ const fetchBunja = bunja([UrlScope], (url) => {
172
+ const queryAtom = atomWithQuery((get) => ({
173
+ queryKey: [url],
174
+ queryFn: async () => (await fetch(url)).json(),
175
+ }));
176
+ return { queryAtom };
177
+ });
178
+
179
+ function ParentComponent() {
180
+ return (
181
+ <>
182
+ <UrlContext.Provider value="https://example.com/foo">
183
+ <ChildComponent />
184
+ </UrlContext.Provider>
185
+ <UrlContext.Provider value="https://example.com/bar">
186
+ <ChildComponent />
187
+ </UrlContext.Provider>
188
+ </>
189
+ );
190
+ }
191
+
192
+ function ChildComponent() {
193
+ const { queryAtom } = useBunja(fetchBunja);
194
+ const { data, isPending, isError } = useAtomValue(queryAtom);
195
+ // Your component logic here
196
+ }
197
+ ```
198
+
199
+ You can use the `createScopeFromContext` function to handle both the creation of the scope and the binding to the context in one step.
200
+
201
+ ```ts
202
+ import { createContext } from "react";
203
+ import { createScopeFromContext } from "bunja/react";
204
+
205
+ const UrlContext = createContext("https://example.com/");
206
+ const UrlScope = createScopeFromContext(UrlContext);
207
+ ```
208
+
209
+ #### Injecting dependencies directly into the scope
210
+
211
+ You might want to use a bunja directly within a React component where the values to be injected into the scope are created.
212
+
213
+ In such cases, you can use the inject function to inject values into the scope without wrapping the context separately.
214
+
215
+ ```tsx
216
+ import { inject } from "bunja/react";
217
+
218
+ function MyComponent() {
219
+ const { queryAtom } = useBunja(
220
+ fetchBunja,
221
+ inject([[UrlScope, "https://example.com/"]])
222
+ );
223
+ const { data, isPending, isError } = useAtomValue(queryAtom);
224
+ // Your component logic here
225
+ }
226
+ ```
package/bunja.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export type Dep<T> = Bunja<T> | Scope<T>;
2
2
 
3
+ const bunjaEffectSymbol: unique symbol = Symbol("Bunja.effect");
4
+ type BunjaEffectSymbol = typeof bunjaEffectSymbol;
5
+
3
6
  export class Bunja<T> {
4
7
  public static readonly bunjas: Bunja<any>[] = [];
5
8
  public readonly id: number;
@@ -9,27 +12,29 @@ export class Bunja<T> {
9
12
  public parents: Bunja<any>[], // one depth parents
10
13
  public relatedBunjas: Bunja<any>[], // toposorted parents without self
11
14
  public relatedScopes: Scope<any>[], // deduped
12
- public init: (...args: any[]) => T & BunjaValue
15
+ public init: (...args: any[]) => T & BunjaValue,
13
16
  ) {
14
17
  this.id = Bunja.bunjas.length;
15
18
  Bunja.bunjas.push(this);
16
19
  }
17
- static readonly effect = Symbol("Bunja.effect");
18
- toString() {
20
+ static readonly effect: BunjaEffectSymbol = bunjaEffectSymbol;
21
+ toString(): string {
19
22
  const { id, debugLabel } = this;
20
23
  return `[Bunja:${id}${debugLabel && ` - ${debugLabel}`}]`;
21
24
  }
22
25
  }
23
26
 
27
+ export type HashFn<T = any, U = any> = (value: T) => U;
28
+
24
29
  export class Scope<T> {
25
30
  public static readonly scopes: Scope<any>[] = [];
26
31
  public readonly id: number;
27
32
  public debugLabel: string = "";
28
- constructor() {
33
+ constructor(public readonly hash: HashFn = id) {
29
34
  this.id = Scope.scopes.length;
30
35
  Scope.scopes.push(this);
31
36
  }
32
- toString() {
37
+ toString(): string {
33
38
  const { id, debugLabel } = this;
34
39
  return `[Scope:${id}${debugLabel && ` - ${debugLabel}`}]`;
35
40
  }
@@ -40,12 +45,19 @@ export type ReadScope = <T>(scope: Scope<T>) => T;
40
45
  export class BunjaStore {
41
46
  #bunjas: Record<string, BunjaInstance> = {};
42
47
  #scopes: Map<Scope<any>, Map<any, ScopeInstance>> = new Map();
43
- get<T>(bunja: Bunja<T>, readScope: ReadScope) {
48
+ get<T>(
49
+ bunja: Bunja<T>,
50
+ readScope: ReadScope,
51
+ ): {
52
+ value: T;
53
+ mount: () => () => void;
54
+ deps: any[];
55
+ } {
44
56
  const scopeInstanceMap = new Map(
45
57
  bunja.relatedScopes.map((scope) => [
46
58
  scope,
47
59
  this.#getScopeInstance(scope, readScope(scope)),
48
- ])
60
+ ]),
49
61
  );
50
62
  const bunjaInstance = this.#getBunjaInstance(bunja, scopeInstanceMap);
51
63
  const { relatedBunjaInstanceMap } = bunjaInstance; // toposorted
@@ -55,7 +67,7 @@ export class BunjaStore {
55
67
  relatedBunjaInstanceMap.forEach((related) => related.add());
56
68
  bunjaInstance.add();
57
69
  scopeInstanceMap.forEach((scope) => scope.add());
58
- return function unmount() {
70
+ return function unmount(): void {
59
71
  // concern: reverse order?
60
72
  relatedBunjaInstanceMap.forEach((related) => related.sub());
61
73
  bunjaInstance.sub();
@@ -67,10 +79,10 @@ export class BunjaStore {
67
79
  }
68
80
  #getBunjaInstance(
69
81
  bunja: Bunja<any>,
70
- scopeInstanceMap: Map<Scope<any>, ScopeInstance>
82
+ scopeInstanceMap: Map<Scope<any>, ScopeInstance>,
71
83
  ): BunjaInstance {
72
84
  const localScopeInstanceMap = new Map(
73
- bunja.relatedScopes.map((scope) => [scope, scopeInstanceMap.get(scope)!])
85
+ bunja.relatedScopes.map((scope) => [scope, scopeInstanceMap.get(scope)!]),
74
86
  );
75
87
  const scopeInstanceIds = Array.from(localScopeInstanceMap.values())
76
88
  .map(({ instanceId }) => instanceId)
@@ -81,7 +93,7 @@ export class BunjaStore {
81
93
  bunja.relatedBunjas.map((relatedBunja) => [
82
94
  relatedBunja,
83
95
  this.#getBunjaInstance(relatedBunja, scopeInstanceMap),
84
- ])
96
+ ]),
85
97
  );
86
98
  const args = bunja.deps.map((dep) => {
87
99
  if (dep instanceof Bunja) return relatedBunjaInstanceMap.get(dep)!.value;
@@ -92,76 +104,78 @@ export class BunjaStore {
92
104
  () => delete this.#bunjas[bunjaInstanceId],
93
105
  bunjaInstanceId,
94
106
  relatedBunjaInstanceMap,
95
- bunja.init.apply(bunja, args)
107
+ bunja.init.apply(bunja, args),
96
108
  );
97
109
  this.#bunjas[bunjaInstanceId] = bunjaInstance;
98
110
  return bunjaInstance;
99
111
  }
100
112
  #getScopeInstance(scope: Scope<any>, value: any): ScopeInstance {
101
- const scopeInstanceMap =
102
- this.#scopes.get(scope) ?? this.#scopes.set(scope, new Map()).get(scope)!;
113
+ const key = scope.hash(value);
114
+ const scopeInstanceMap = this.#scopes.get(scope) ??
115
+ this.#scopes.set(scope, new Map()).get(scope)!;
103
116
  const init = () =>
104
117
  new ScopeInstance(
105
- () => scopeInstanceMap.delete(value),
118
+ () => scopeInstanceMap.delete(key),
106
119
  ScopeInstance.counter++,
107
120
  scope,
108
- value
121
+ value,
109
122
  );
110
123
  return (
111
- scopeInstanceMap.get(value) ??
112
- scopeInstanceMap.set(value, init()).get(value)!
124
+ scopeInstanceMap.get(key) ??
125
+ scopeInstanceMap.set(key, init()).get(key)!
113
126
  );
114
127
  }
115
128
  }
116
129
 
117
- export const createBunjaStore = () => new BunjaStore();
130
+ export const createBunjaStore = (): BunjaStore => new BunjaStore();
118
131
 
119
132
  export type BunjaEffectFn = () => () => void;
120
133
  export interface BunjaValue {
121
134
  [Bunja.effect]?: BunjaEffectFn;
122
135
  }
123
136
 
124
- export function bunja<T>(deps: [], init: () => T & BunjaValue): Bunja<T>;
125
- export function bunja<T, U>(
126
- deps: [Dep<U>],
127
- init: (u: U) => T & BunjaValue
128
- ): Bunja<T>;
129
- export function bunja<T, U, V>(
130
- deps: [Dep<U>, Dep<V>],
131
- init: (u: U, v: V) => T & BunjaValue
132
- ): Bunja<T>;
133
- export function bunja<T, U, V, W>(
134
- deps: [Dep<U>, Dep<V>, Dep<W>],
135
- init: (u: U, v: V, w: W) => T & BunjaValue
136
- ): Bunja<T>;
137
- export function bunja<T, U, V, W, X>(
138
- deps: [Dep<U>, Dep<V>, Dep<W>, Dep<X>],
139
- init: (u: U, v: V, w: W, x: X) => T & BunjaValue
140
- ): Bunja<T>;
141
- export function bunja<T, U, V, W, X, Y>(
142
- deps: [Dep<U>, Dep<V>, Dep<W>, Dep<X>, Dep<Y>],
143
- init: (u: U, v: V, w: W, x: X, y: Y) => T & BunjaValue
144
- ): Bunja<T>;
145
- export function bunja<T, U, V, W, X, Y, Z>(
146
- deps: [Dep<U>, Dep<V>, Dep<W>, Dep<X>, Dep<Y>, Dep<Z>],
147
- init: (u: U, v: V, w: W, x: X, y: Y, z: Z) => T & BunjaValue
148
- ): Bunja<T>;
149
- export function bunja<T, const U extends any[]>(
137
+ function bunjaImpl<T, const U extends any[]>(
150
138
  deps: { [K in keyof U]: Dep<U[K]> },
151
- init: (...args: U) => T & BunjaValue
139
+ init: (...args: U) => T & BunjaValue,
152
140
  ): Bunja<T> {
153
141
  const parents = deps.filter((dep) => dep instanceof Bunja) as Bunja<any>[];
154
142
  const scopes = deps.filter((dep) => dep instanceof Scope) as Scope<any>[];
155
143
  const relatedBunjas = toposort(parents);
156
144
  const relatedScopes = Array.from(
157
- new Set([...scopes, ...parents.flatMap((parent) => parent.relatedScopes)])
145
+ new Set([...scopes, ...parents.flatMap((parent) => parent.relatedScopes)]),
158
146
  );
159
147
  return new Bunja(deps, parents, relatedBunjas, relatedScopes, init as any);
160
148
  }
161
- bunja.effect = Bunja.effect;
149
+ bunjaImpl.effect = Bunja.effect;
162
150
 
163
- export function createScope<T>(): Scope<T> {
164
- return new Scope();
151
+ export const bunja: {
152
+ <T>(deps: [], init: () => T & BunjaValue): Bunja<T>;
153
+ <T, U>(deps: [Dep<U>], init: (u: U) => T & BunjaValue): Bunja<T>;
154
+ <T, U, V>(
155
+ deps: [Dep<U>, Dep<V>],
156
+ init: (u: U, v: V) => T & BunjaValue,
157
+ ): Bunja<T>;
158
+ <T, U, V, W>(
159
+ deps: [Dep<U>, Dep<V>, Dep<W>],
160
+ init: (u: U, v: V, w: W) => T & BunjaValue,
161
+ ): Bunja<T>;
162
+ <T, U, V, W, X>(
163
+ deps: [Dep<U>, Dep<V>, Dep<W>, Dep<X>],
164
+ init: (u: U, v: V, w: W, x: X) => T & BunjaValue,
165
+ ): Bunja<T>;
166
+ <T, U, V, W, X, Y>(
167
+ deps: [Dep<U>, Dep<V>, Dep<W>, Dep<X>, Dep<Y>],
168
+ init: (u: U, v: V, w: W, x: X, y: Y) => T & BunjaValue,
169
+ ): Bunja<T>;
170
+ <T, U, V, W, X, Y, Z>(
171
+ deps: [Dep<U>, Dep<V>, Dep<W>, Dep<X>, Dep<Y>, Dep<Z>],
172
+ init: (u: U, v: V, w: W, x: X, y: Y, z: Z) => T & BunjaValue,
173
+ ): Bunja<T>;
174
+ readonly effect: BunjaEffectSymbol;
175
+ } = bunjaImpl;
176
+
177
+ export function createScope<T>(hash?: HashFn): Scope<T> {
178
+ return new Scope(hash);
165
179
  }
166
180
 
167
181
  abstract class RefCounter {
@@ -183,6 +197,7 @@ abstract class RefCounter {
183
197
  abstract dispose(): void;
184
198
  }
185
199
 
200
+ const id = <T>(x: T): T => x;
186
201
  const noop = () => {};
187
202
  class BunjaInstance extends RefCounter {
188
203
  #cleanup: (() => void) | undefined;
@@ -191,7 +206,7 @@ class BunjaInstance extends RefCounter {
191
206
  dispose: () => void,
192
207
  public instanceId: string,
193
208
  public relatedBunjaInstanceMap: Map<Bunja<any>, BunjaInstance>,
194
- public value: BunjaValue
209
+ public value: BunjaValue,
195
210
  ) {
196
211
  super();
197
212
  this.#dispose = () => {
@@ -199,7 +214,7 @@ class BunjaInstance extends RefCounter {
199
214
  dispose();
200
215
  };
201
216
  }
202
- add() {
217
+ override add() {
203
218
  this.#cleanup ??= this.value[Bunja.effect]?.() ?? noop;
204
219
  super.add();
205
220
  }
@@ -214,7 +229,7 @@ class ScopeInstance extends RefCounter {
214
229
  public dispose: () => void,
215
230
  public instanceId: number,
216
231
  public scope: Scope<any>,
217
- public value: any
232
+ public value: any,
218
233
  ) {
219
234
  super();
220
235
  }
package/deno.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@disjukr/bunja",
3
+ "version": "1.0.0",
4
+ "license": "Zlib",
5
+ "exports": {
6
+ ".": "./bunja.ts",
7
+ "./react": "./react.ts"
8
+ }
9
+ }