bunja 2.1.0 → 3.0.0-alpha.3

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/bunja.ts CHANGED
@@ -3,24 +3,76 @@
3
3
  const __DEV__ = process.env.NODE_ENV !== "production";
4
4
 
5
5
  export interface BunjaFn {
6
- <T>(init: () => T): Bunja<T>;
6
+ <T>(init: () => T): Bunja<T, NoSeed>;
7
+ withSeed: BunjaWithSeedFn;
7
8
  use: BunjaUseFn;
8
- fork: BunjaForkFn;
9
+ will: BunjaWillFn;
9
10
  effect: BunjaEffectFn;
10
11
  }
11
12
  export const bunja: BunjaFn = bunjaFn;
12
- function bunjaFn<T>(init: () => T): Bunja<T> {
13
- return new Bunja(init);
13
+ function bunjaFn<T>(init: () => T): Bunja<T, NoSeed> {
14
+ return new Bunja(() => init(), NO_SEED);
14
15
  }
15
- bunjaFn.use = invalidUse as BunjaUseFn;
16
- bunjaFn.fork = invalidFork as BunjaForkFn;
17
- bunjaFn.effect = invalidEffect as BunjaEffectFn;
16
+ const NO_SEED = Symbol("bunja.noSeed");
17
+ export type NoSeed = typeof NO_SEED;
18
+ bunjaFn.withSeed = function withSeed<Seed, T>(
19
+ defaultSeed: Seed,
20
+ init: (seed: Seed) => T,
21
+ ): Bunja<T, Seed> {
22
+ return new Bunja(init, defaultSeed);
23
+ };
24
+ bunjaFn.use =
25
+ ((dep: unknown, scopeValuePairs?: ScopeValuePairs) =>
26
+ (getCurrentFrame("`bunja.use`").use as (
27
+ dep: unknown,
28
+ scopeValuePairs?: ScopeValuePairs,
29
+ ) => unknown)(
30
+ dep,
31
+ scopeValuePairs,
32
+ )) as BunjaUseFn;
33
+ bunjaFn.will =
34
+ ((dep: unknown, scopeValuePairs?: ScopeValuePairs) =>
35
+ (getCurrentFrame("`bunja.will`").will as (
36
+ dep: unknown,
37
+ scopeValuePairs?: ScopeValuePairs,
38
+ ) => unknown)(
39
+ dep,
40
+ scopeValuePairs,
41
+ )) as BunjaWillFn;
42
+ bunjaFn.effect =
43
+ ((callback: BunjaEffectCallback) =>
44
+ getCurrentFrame("`bunja.effect`").effect(callback)) as BunjaEffectFn;
18
45
 
19
- export type BunjaUseFn = <T>(dep: Dep<T>) => T;
20
- export type BunjaForkFn = <T>(
21
- bunja: Bunja<T>,
22
- scopeValuePairs: ScopeValuePair<any>[],
23
- ) => T;
46
+ export type BunjaWithSeedFn = <Seed, T>(
47
+ defaultSeed: Seed,
48
+ init: (seed: Seed) => T,
49
+ ) => Bunja<T, Seed>;
50
+ export type ScopeValuePairs = ScopeValuePair<any>[];
51
+ type BunjaRefBase<T, Seed> = {
52
+ bunja: Bunja<T, Seed>;
53
+ with?: ScopeValuePairs;
54
+ };
55
+ export type BunjaGetRef<T, Seed = NoSeed> =
56
+ & BunjaRefBase<T, Seed>
57
+ & ([Seed] extends [NoSeed] ? { seed?: never } : { seed?: Seed });
58
+ export type BunjaRef<T, Seed = NoSeed> = BunjaGetRef<T, Seed>;
59
+ type BunjaPrebakeRef<T, Seed = NoSeed> = BunjaRefBase<T, Seed> & {
60
+ seed?: never;
61
+ };
62
+ export interface BunjaUseFn {
63
+ <T>(dep: Scope<T>): T;
64
+ <T, Seed>(dep: Bunja<T, Seed>): T;
65
+ <T, Seed>(bunja: Bunja<T, Seed>, scopeValuePairs: ScopeValuePairs): T;
66
+ <T, Seed>(ref: BunjaRef<T, Seed>): T;
67
+ }
68
+ export interface BunjaWillFn {
69
+ <T, Seed>(dep: Bunja<T, Seed>): () => T;
70
+ <T, Seed>(
71
+ bunja: Bunja<T, Seed>,
72
+ scopeValuePairs: ScopeValuePairs,
73
+ ): () => T;
74
+ <T, Seed>(ref: BunjaRef<T, Seed>): () => T;
75
+ }
24
76
  export type BunjaEffectFn = (callback: BunjaEffectCallback) => void;
25
77
  export type BunjaEffectCallback = () => (() => void) | void;
26
78
 
@@ -38,32 +90,35 @@ export function createBunjaStore(config?: CreateBunjaStoreConfig): BunjaStore {
38
90
  return store;
39
91
  }
40
92
 
41
- export type Dep<T> = Bunja<T> | Scope<T>;
93
+ export type Dep<T> = Bunja<T, any> | Scope<T>;
42
94
 
43
- function invalidUse() {
44
- throw new Error(
45
- "`bunja.use` can only be used inside a bunja init function.",
46
- );
47
- }
48
- function invalidFork() {
49
- throw new Error(
50
- "`bunja.fork` can only be used inside a bunja init function.",
51
- );
52
- }
53
- function invalidEffect() {
54
- throw new Error(
55
- "`bunja.effect` can only be used inside a bunja init function.",
56
- );
95
+ type AnyBunja = Bunja<any, any>;
96
+ type ScopeInstanceMap = Map<Scope<unknown>, ScopeInstance>;
97
+ type BunjaDependencyEdge = "required" | "optional";
98
+
99
+ interface BunjaFrame {
100
+ use: BunjaUseFn;
101
+ will: BunjaWillFn;
102
+ effect: BunjaEffectFn;
57
103
  }
58
104
 
59
- interface BunjaStoreGetContext {
60
- bunjaInstance: BunjaInstance;
61
- bunjaInstanceMap: BunjaInstanceMap;
62
- scopeInstanceMap: ScopeInstanceMap;
105
+ const frameStack: BunjaFrame[] = [];
106
+ function getCurrentFrame(api: string): BunjaFrame {
107
+ const frame = frameStack[frameStack.length - 1];
108
+ if (!frame) {
109
+ throw new Error(`${api} can only be used inside a bunja init function.`);
110
+ }
111
+ return frame;
63
112
  }
64
113
 
65
- type BunjaInstanceMap = Map<Bunja<unknown>, BunjaInstance>;
66
- type ScopeInstanceMap = Map<Scope<unknown>, ScopeInstance>;
114
+ function runWithFrame<T>(frame: BunjaFrame, fn: () => T): T {
115
+ frameStack.push(frame);
116
+ try {
117
+ return fn();
118
+ } finally {
119
+ frameStack.pop();
120
+ }
121
+ }
67
122
 
68
123
  interface InternalState {
69
124
  bunjas: Record<string, BunjaInstance>;
@@ -71,25 +126,178 @@ interface InternalState {
71
126
  instantiating: boolean;
72
127
  }
73
128
 
74
- interface BunjaBakingContext {
75
- currentBunja: Bunja<unknown>;
129
+ interface BunjaInitFrame extends BunjaFrame {
130
+ currentBunja: AnyBunja;
131
+ readScope: ReadScope;
132
+ scopeInstanceMap: ScopeInstanceMap;
133
+ inProgressBunjas: Set<AnyBunja>;
134
+ effects: BunjaEffectCallback[];
135
+ activeDependencyIds: Set<string>;
136
+ activeDependencyRecipes: ActiveDependencyRecipe[];
137
+ activeDependencyMounts: Map<string, () => () => void>;
138
+ activeDependencyDeps: ScopeInstance[];
139
+ }
140
+
141
+ interface BunjaPrebakeFrame extends BunjaFrame {
142
+ currentBunja: AnyBunja;
143
+ readScope: ReadScope;
144
+ prebakeContext: BunjaPrebakeContext;
145
+ }
146
+
147
+ interface BunjaPrebakeContext {
148
+ inProgressBunjas: Set<AnyBunja>;
149
+ values: Map<AnyBunja, unknown>;
150
+ }
151
+
152
+ type AnyNormalizedBunjaRef = NormalizedBunjaRef<any, any>;
153
+ interface NormalizedBunjaRef<T, Seed> {
154
+ bunja: Bunja<T, Seed>;
155
+ scopeValuePairs: ScopeValuePairs;
156
+ }
157
+
158
+ interface NormalizedBunjaRuntimeRef<T, Seed>
159
+ extends NormalizedBunjaRef<T, Seed> {
160
+ seed: Seed;
161
+ }
162
+
163
+ interface ActiveDependencyRecipe {
164
+ ref: AnyNormalizedBunjaRef;
165
+ seed: unknown;
166
+ }
167
+
168
+ interface BunjaInstanceRecipe {
169
+ activeDependencies: ActiveDependencyRecipe[];
170
+ }
171
+
172
+ interface ResolvedBunja<T> {
173
+ value: T;
174
+ instance: BunjaInstance;
175
+ mount: () => () => void;
176
+ deps: ScopeInstance[];
177
+ }
178
+
179
+ interface ResolvedActiveDependencyRecipe {
180
+ activeDependencyIds: Set<string>;
181
+ deps: ScopeInstance[];
76
182
  }
77
183
 
78
184
  export type WrapInstanceFn = <T>(fn: (dispose: () => void) => T) => T;
79
185
  const defaultWrapInstanceFn: WrapInstanceFn = (fn) => fn(noop);
80
186
 
187
+ function normalizeBunjaRuntimeRef<T, Seed>(
188
+ bunjaOrRef: Bunja<T, Seed> | BunjaRef<T, Seed>,
189
+ scopeValuePairs: ScopeValuePairs = [],
190
+ ): NormalizedBunjaRuntimeRef<T, Seed> {
191
+ if (bunjaOrRef instanceof Bunja) {
192
+ return {
193
+ bunja: bunjaOrRef,
194
+ scopeValuePairs,
195
+ seed: bunjaOrRef.defaultSeed,
196
+ };
197
+ }
198
+ const { bunja, with: refScopeValuePairs = [] } = bunjaOrRef;
199
+ return {
200
+ bunja,
201
+ scopeValuePairs: refScopeValuePairs,
202
+ seed: "seed" in bunjaOrRef ? (bunjaOrRef.seed as Seed) : bunja.defaultSeed,
203
+ };
204
+ }
205
+
206
+ function normalizeBunjaPrebakeRef<T, Seed>(
207
+ bunjaOrRef: Bunja<T, Seed> | BunjaPrebakeRef<T, Seed>,
208
+ ): NormalizedBunjaRef<T, Seed> {
209
+ if (bunjaOrRef instanceof Bunja) {
210
+ return {
211
+ bunja: bunjaOrRef,
212
+ scopeValuePairs: [],
213
+ };
214
+ }
215
+ if ("seed" in bunjaOrRef) {
216
+ throw new Error("A bunja seed cannot be provided to `store.prebake`.");
217
+ }
218
+ const { bunja, with: refScopeValuePairs = [] } = bunjaOrRef;
219
+ return {
220
+ bunja,
221
+ scopeValuePairs: refScopeValuePairs,
222
+ };
223
+ }
224
+
225
+ function toBunjaGraphRef<T, Seed>(
226
+ bunjaRef: NormalizedBunjaRef<T, Seed>,
227
+ ): NormalizedBunjaRef<T, Seed> {
228
+ return {
229
+ bunja: bunjaRef.bunja,
230
+ scopeValuePairs: bunjaRef.scopeValuePairs,
231
+ };
232
+ }
233
+
234
+ function isBunjaRef(value: unknown): value is BunjaRef<any, any> {
235
+ return (
236
+ typeof value === "object" &&
237
+ value !== null &&
238
+ "bunja" in value &&
239
+ (value as { bunja: unknown }).bunja instanceof Bunja
240
+ );
241
+ }
242
+
243
+ function getBoundScopeSet(
244
+ scopeValuePairs: ScopeValuePairs,
245
+ ): Set<Scope<unknown>> {
246
+ return new Set(
247
+ scopeValuePairs.map(([scope]) => scope as Scope<unknown>),
248
+ );
249
+ }
250
+
251
+ function getScopeInstances(
252
+ scopes: Scope<unknown>[],
253
+ scopeInstanceMap: ScopeInstanceMap,
254
+ excludeScopes: Set<Scope<unknown>> = new Set(),
255
+ ): ScopeInstance[] {
256
+ return scopes
257
+ .filter((scope) => !excludeScopes.has(scope))
258
+ .map((scope) => scopeInstanceMap.get(scope)!);
259
+ }
260
+
261
+ function dedupeScopeInstances(
262
+ scopeInstances: ScopeInstance[],
263
+ ): ScopeInstance[] {
264
+ const seen = new Set<string>();
265
+ const result: ScopeInstance[] = [];
266
+ for (const scopeInstance of scopeInstances) {
267
+ if (seen.has(scopeInstance.id)) continue;
268
+ seen.add(scopeInstance.id);
269
+ result.push(scopeInstance);
270
+ }
271
+ return result;
272
+ }
273
+
274
+ function dedupeBunjas(bunjas: AnyBunja[]): AnyBunja[] {
275
+ return Array.from(new Set(bunjas));
276
+ }
277
+
278
+ function addUniqueBunjaRef(
279
+ refs: AnyNormalizedBunjaRef[],
280
+ ref: AnyNormalizedBunjaRef,
281
+ ): void {
282
+ if (!refs.includes(ref)) refs.push(ref);
283
+ }
284
+
285
+ function createBunjaPrebakeContext(): BunjaPrebakeContext {
286
+ return {
287
+ inProgressBunjas: new Set(),
288
+ values: new Map(),
289
+ };
290
+ }
291
+
81
292
  export class BunjaStore {
82
293
  private static counter: number = 0;
83
294
  readonly id: string = String(BunjaStore.counter++);
84
295
  #bunjas: Record<string, BunjaInstance> = {};
296
+ #bunjaBuckets: Map<string, Set<string>> = new Map();
85
297
  #scopes: Map<Scope<unknown>, Map<unknown, ScopeInstance>> = new Map();
86
- #bakingContext: BunjaBakingContext | undefined;
87
298
  wrapInstance: WrapInstanceFn = defaultWrapInstanceFn;
88
299
  constructor() {
89
- if (__DEV__) {
90
- devtoolsGlobalHook.stores[this.id] = this;
91
- devtoolsGlobalHook.emit("storeCreated", { storeId: this.id });
92
- }
300
+ if (__DEV__) devtoolsGlobalHook.emit("storeCreated", { storeId: this.id });
93
301
  }
94
302
  get _internalState(): InternalState | undefined {
95
303
  if (__DEV__) {
@@ -97,7 +305,7 @@ export class BunjaStore {
97
305
  bunjas: this.#bunjas,
98
306
  scopes: this.#scopes,
99
307
  get instantiating() {
100
- return bunja.use != invalidUse;
308
+ return frameStack.length > 0;
101
309
  },
102
310
  };
103
311
  }
@@ -105,159 +313,488 @@ export class BunjaStore {
105
313
  }
106
314
  dispose(): void {
107
315
  for (const instance of Object.values(this.#bunjas)) instance.dispose();
108
- for (const instanceMap of Object.values(this.#scopes)) {
316
+ for (const instanceMap of this.#scopes.values()) {
109
317
  for (const instance of instanceMap.values()) instance.dispose();
110
318
  }
111
319
  this.#bunjas = {};
320
+ this.#bunjaBuckets = new Map();
112
321
  this.#scopes = new Map();
322
+ if (__DEV__) devtoolsGlobalHook.emit("storeDisposed", { storeId: this.id });
323
+ }
324
+ get<T, Seed>(
325
+ bunjaOrRef: Bunja<T, Seed> | BunjaGetRef<T, Seed>,
326
+ readScope: ReadScope,
327
+ ): BunjaStoreGetResult<T> {
328
+ const bunjaRef = normalizeBunjaRuntimeRef(bunjaOrRef);
329
+ const resolved = this.#resolveBunjaRef(
330
+ toBunjaGraphRef(bunjaRef),
331
+ readScope,
332
+ new Set(),
333
+ bunjaRef.seed,
334
+ );
335
+ const result: BunjaStoreGetResult<T> = {
336
+ value: resolved.value,
337
+ mount: resolved.mount,
338
+ deps: resolved.deps.map(({ value }) => value),
339
+ };
113
340
  if (__DEV__) {
114
- devtoolsGlobalHook.emit("storeDisposed", { storeId: this.id });
115
- delete devtoolsGlobalHook.stores[this.id];
341
+ result.bunjaInstance = resolved.instance;
342
+ devtoolsGlobalHook.emit("getCalled", {
343
+ storeId: this.id,
344
+ bunjaInstanceId: resolved.instance.id,
345
+ });
116
346
  }
347
+ return result;
117
348
  }
118
- get<T>(bunja: Bunja<T>, readScope: ReadScope): BunjaStoreGetResult<T> {
119
- const originalUse = bunjaFn.use;
349
+ prebake<T, Seed>(
350
+ bunjaOrRef: Bunja<T, Seed> | BunjaPrebakeRef<T, Seed>,
351
+ readScope: ReadScope,
352
+ ): BunjaStorePrebakeResult {
353
+ const bunjaRef = normalizeBunjaPrebakeRef(bunjaOrRef);
354
+ this.#prebakeBunjaRef(
355
+ bunjaRef,
356
+ readScope,
357
+ createBunjaPrebakeContext(),
358
+ );
359
+ return {
360
+ relatedBunjas: bunjaRef.bunja.relatedBunjas,
361
+ requiredScopes: bunjaRef.bunja.requiredScopes,
362
+ };
363
+ }
364
+ #resolveBunjaRef<T, Seed>(
365
+ bunjaRef: NormalizedBunjaRef<T, Seed>,
366
+ readScope: ReadScope,
367
+ inProgressBunjas: Set<AnyBunja>,
368
+ seed: Seed = bunjaRef.bunja.defaultSeed,
369
+ ): ResolvedBunja<T> {
370
+ const { bunja } = bunjaRef;
371
+ if (inProgressBunjas.has(bunja)) {
372
+ throw new Error("Circular bunja dependency detected.");
373
+ }
374
+ const resolvedReadScope = bunjaRef.scopeValuePairs.length > 0
375
+ ? createReadScopeFn(bunjaRef.scopeValuePairs, readScope)
376
+ : readScope;
377
+ inProgressBunjas.add(bunja);
120
378
  try {
121
- const { bunjaInstance, bunjaInstanceMap, scopeInstanceMap } = bunja.baked
122
- ? this.#getBaked(bunja, readScope)
123
- : this.#getUnbaked(bunja, readScope);
124
- const result: BunjaStoreGetResult<T> = {
125
- value: bunjaInstance.value as T,
126
- mount: () => {
127
- bunjaInstanceMap.forEach((instance) => instance.add());
128
- bunjaInstance.add();
129
- scopeInstanceMap.forEach((instance) => instance.add());
130
- const unmount = () => {
131
- bunjaInstanceMap.forEach((instance) => instance.sub());
132
- bunjaInstance.sub();
133
- scopeInstanceMap.forEach((instance) => instance.sub());
134
- };
135
- return unmount;
136
- },
137
- deps: Array.from(scopeInstanceMap.values()).map(({ value }) => value),
138
- };
139
- if (__DEV__) {
140
- result.bunjaInstance = bunjaInstance;
141
- devtoolsGlobalHook.emit("getCalled", {
142
- storeId: this.id,
143
- bunjaInstanceId: bunjaInstance.id,
144
- });
379
+ if (!bunja.baked) {
380
+ return this.#createResolvedBunja(
381
+ bunjaRef,
382
+ resolvedReadScope,
383
+ inProgressBunjas,
384
+ seed,
385
+ );
386
+ }
387
+ const scopeInstanceMap = this.#resolveScopeInstanceMap(
388
+ bunja,
389
+ resolvedReadScope,
390
+ );
391
+ const boundScopes = getBoundScopeSet(bunjaRef.scopeValuePairs);
392
+ const scopeInstances = getScopeInstances(
393
+ bunja.requiredScopes,
394
+ scopeInstanceMap,
395
+ );
396
+ const directDeps = getScopeInstances(
397
+ bunja.requiredScopes,
398
+ scopeInstanceMap,
399
+ boundScopes,
400
+ );
401
+ const baseId = bunja.calcBaseInstanceId(scopeInstanceMap);
402
+ const bucket = this.#bunjaBuckets.get(baseId);
403
+ if (bucket) {
404
+ for (const candidateId of Array.from(bucket)) {
405
+ const candidate = this.#bunjas[candidateId];
406
+ if (!candidate) {
407
+ bucket.delete(candidateId);
408
+ continue;
409
+ }
410
+ const activeDeps = this.#resolveActiveDependencyRecipe(
411
+ candidate.recipe,
412
+ resolvedReadScope,
413
+ inProgressBunjas,
414
+ );
415
+ const currentId = bunja.calcInstanceId(
416
+ scopeInstanceMap,
417
+ activeDeps.activeDependencyIds,
418
+ );
419
+ const instance = currentId === candidate.id
420
+ ? candidate
421
+ : this.#bunjas[currentId];
422
+ if (!instance) continue;
423
+ return this.#toResolvedBunja(
424
+ instance,
425
+ scopeInstances,
426
+ directDeps,
427
+ activeDeps.deps,
428
+ );
429
+ }
145
430
  }
146
- return result;
431
+ return this.#createResolvedBunja(
432
+ bunjaRef,
433
+ resolvedReadScope,
434
+ inProgressBunjas,
435
+ seed,
436
+ scopeInstanceMap,
437
+ );
147
438
  } finally {
148
- bunjaFn.use = originalUse;
439
+ inProgressBunjas.delete(bunja);
149
440
  }
150
441
  }
151
- #getBaked<T>(bunja: Bunja<T>, readScope: ReadScope): BunjaStoreGetContext {
152
- const scopeInstanceMap = new Map(
153
- bunja.relatedScopes.map((scope) => [
154
- scope,
155
- this.#getScopeInstance(scope, readScope(scope)),
156
- ]),
157
- );
158
- const bunjaInstanceMap = new Map();
159
- bunjaFn.use = <T>(dep: Dep<T>) => {
160
- if (dep instanceof Bunja) {
161
- return bunjaInstanceMap.get(dep as Bunja<unknown>)!.value as T;
162
- }
163
- if (dep instanceof Scope) {
164
- return scopeInstanceMap.get(dep as Scope<unknown>)!.value as T;
442
+ #createResolvedBunja<T, Seed>(
443
+ bunjaRef: NormalizedBunjaRef<T, Seed>,
444
+ readScope: ReadScope,
445
+ inProgressBunjas: Set<AnyBunja>,
446
+ seed: Seed,
447
+ initialScopeInstanceMap: ScopeInstanceMap = new Map(),
448
+ ): ResolvedBunja<T> {
449
+ const { bunja } = bunjaRef;
450
+ return this.wrapInstance((dispose) => {
451
+ let instanceCreated = false;
452
+ let disposed = false;
453
+ const disposeOnce = () => {
454
+ if (disposed) return;
455
+ disposed = true;
456
+ dispose();
457
+ };
458
+ try {
459
+ const frame = this.#createInitFrame(
460
+ bunja,
461
+ readScope,
462
+ initialScopeInstanceMap,
463
+ inProgressBunjas,
464
+ );
465
+ const value = runWithFrame(frame, () => bunja.init(seed));
466
+ if (!bunja.baked) bunja.bake();
467
+ this.#ensureRequiredScopeInstances(
468
+ bunja,
469
+ frame.scopeInstanceMap,
470
+ readScope,
471
+ );
472
+ const boundScopes = getBoundScopeSet(bunjaRef.scopeValuePairs);
473
+ const scopeInstances = getScopeInstances(
474
+ bunja.requiredScopes,
475
+ frame.scopeInstanceMap,
476
+ );
477
+ const directDeps = getScopeInstances(
478
+ bunja.requiredScopes,
479
+ frame.scopeInstanceMap,
480
+ boundScopes,
481
+ );
482
+ const baseId = bunja.calcBaseInstanceId(frame.scopeInstanceMap);
483
+ const id = bunja.calcInstanceId(
484
+ frame.scopeInstanceMap,
485
+ frame.activeDependencyIds,
486
+ );
487
+ const existing = this.#bunjas[id];
488
+ if (existing) {
489
+ disposeOnce();
490
+ return this.#toResolvedBunja(
491
+ existing,
492
+ scopeInstances,
493
+ directDeps,
494
+ frame.activeDependencyDeps,
495
+ );
496
+ }
497
+ const instance = this.#createBunjaInstance(
498
+ id,
499
+ baseId,
500
+ value,
501
+ Array.from(frame.activeDependencyMounts.values()),
502
+ frame.effects,
503
+ { activeDependencies: frame.activeDependencyRecipes },
504
+ dispose,
505
+ );
506
+ instanceCreated = true;
507
+ return this.#toResolvedBunja(
508
+ instance,
509
+ scopeInstances,
510
+ directDeps,
511
+ frame.activeDependencyDeps,
512
+ );
513
+ } finally {
514
+ if (!instanceCreated) disposeOnce();
165
515
  }
166
- throw new Error("`bunja.use` can only be used with Bunja or Scope.");
516
+ });
517
+ }
518
+ #toResolvedBunja<T>(
519
+ instance: BunjaInstance,
520
+ scopeInstances: ScopeInstance[],
521
+ directDeps: ScopeInstance[],
522
+ activeDependencyDeps: ScopeInstance[],
523
+ ): ResolvedBunja<T> {
524
+ const deps = dedupeScopeInstances([
525
+ ...directDeps,
526
+ ...activeDependencyDeps,
527
+ ]);
528
+ return {
529
+ value: instance.value as T,
530
+ instance,
531
+ deps,
532
+ mount: () => {
533
+ for (const scopeInstance of scopeInstances) scopeInstance.add();
534
+ instance.add();
535
+ return () => {
536
+ instance.sub();
537
+ for (const scopeInstance of scopeInstances) scopeInstance.sub();
538
+ };
539
+ },
167
540
  };
168
- for (const relatedBunja of bunja.relatedBunjas) {
169
- bunjaInstanceMap.set(
170
- relatedBunja,
171
- this.#getBunjaInstance(relatedBunja, scopeInstanceMap),
541
+ }
542
+ #createInitFrame(
543
+ currentBunja: AnyBunja,
544
+ readScope: ReadScope,
545
+ scopeInstanceMap: ScopeInstanceMap,
546
+ inProgressBunjas: Set<AnyBunja>,
547
+ ): BunjaInitFrame {
548
+ const frame = {
549
+ currentBunja,
550
+ readScope,
551
+ scopeInstanceMap,
552
+ inProgressBunjas,
553
+ effects: [] as BunjaEffectCallback[],
554
+ activeDependencyIds: new Set<string>(),
555
+ activeDependencyRecipes: [] as ActiveDependencyRecipe[],
556
+ activeDependencyMounts: new Map<string, () => () => void>(),
557
+ activeDependencyDeps: [] as ScopeInstance[],
558
+ use: ((dep: unknown, scopeValuePairs?: ScopeValuePairs) => {
559
+ if (dep instanceof Scope) {
560
+ return this.#useScopeInFrame(frame, dep as Scope<unknown>);
561
+ }
562
+ if (dep instanceof Bunja || isBunjaRef(dep)) {
563
+ return this.#useBunjaDependencyInFrame(
564
+ frame,
565
+ normalizeBunjaRuntimeRef(dep, scopeValuePairs),
566
+ "required",
567
+ );
568
+ }
569
+ throw new Error("`bunja.use` can only be used with Bunja or Scope.");
570
+ }) as BunjaUseFn,
571
+ will: ((dep: unknown, scopeValuePairs?: ScopeValuePairs) => {
572
+ if (!(dep instanceof Bunja || isBunjaRef(dep))) {
573
+ throw new Error("`bunja.will` can only be used with Bunja.");
574
+ }
575
+ const bunjaRef = normalizeBunjaRuntimeRef(dep, scopeValuePairs);
576
+ currentBunja.addOptionalBunjaRef(toBunjaGraphRef(bunjaRef));
577
+ return () => {
578
+ if (frameStack[frameStack.length - 1] !== frame) {
579
+ throw new Error(
580
+ "A thunk returned by `bunja.will` can only be called inside the same bunja init function.",
581
+ );
582
+ }
583
+ return this.#useBunjaDependencyInFrame(
584
+ frame,
585
+ bunjaRef,
586
+ "optional",
587
+ );
588
+ };
589
+ }) as BunjaWillFn,
590
+ effect: ((callback: BunjaEffectCallback) => {
591
+ frame.effects.push(callback);
592
+ }) as BunjaEffectFn,
593
+ } satisfies BunjaInitFrame;
594
+ return frame;
595
+ }
596
+ #useScopeInFrame<T>(frame: BunjaInitFrame, scope: Scope<T>): T {
597
+ if (!frame.currentBunja.baked) {
598
+ frame.currentBunja.addScope(scope as Scope<unknown>);
599
+ }
600
+ let scopeInstance = frame.scopeInstanceMap.get(scope as Scope<unknown>);
601
+ if (!scopeInstance) {
602
+ if (frame.currentBunja.baked) {
603
+ throw new Error(
604
+ "`bunja.use(scope)` cannot introduce a new scope after the bunja is baked.",
605
+ );
606
+ }
607
+ scopeInstance = this.#getScopeInstance(
608
+ scope as Scope<unknown>,
609
+ frame.readScope(scope),
172
610
  );
611
+ frame.scopeInstanceMap.set(scope as Scope<unknown>, scopeInstance);
173
612
  }
174
- const bunjaInstance = this.#getBunjaInstance(bunja, scopeInstanceMap);
175
- return { bunjaInstance, bunjaInstanceMap, scopeInstanceMap };
613
+ return scopeInstance.value as T;
176
614
  }
177
- #getUnbaked<T>(bunja: Bunja<T>, readScope: ReadScope): BunjaStoreGetContext {
178
- const bunjaInstanceMap: BunjaInstanceMap = new Map();
179
- const scopeInstanceMap: ScopeInstanceMap = new Map();
180
- function getUse<D extends Dep<unknown>, I extends { value: unknown }>(
181
- map: Map<D, I>,
182
- addDep: (D: D) => void,
183
- getInstance: (dep: D) => I,
184
- ) {
185
- return ((dep) => {
186
- const d = dep as D;
187
- addDep(d);
188
- if (map.has(d)) return map.get(d)!.value as T;
189
- const instance = getInstance(d);
190
- map.set(d, instance);
191
- return instance.value as T;
192
- }) as <T>(dep: Dep<T>) => T;
615
+ #useBunjaDependencyInFrame<T, Seed>(
616
+ frame: BunjaInitFrame,
617
+ bunjaRef: NormalizedBunjaRuntimeRef<T, Seed>,
618
+ edge: BunjaDependencyEdge,
619
+ ): T {
620
+ const graphRef = toBunjaGraphRef(bunjaRef);
621
+ if (edge === "optional" || graphRef.scopeValuePairs.length > 0) {
622
+ frame.currentBunja.addOptionalBunjaRef(graphRef);
623
+ } else if (edge === "required") {
624
+ frame.currentBunja.addRequiredBunjaRef(graphRef);
193
625
  }
194
- const useScope = getUse(
195
- scopeInstanceMap,
196
- (dep) => this.#bakingContext!.currentBunja.addScope(dep),
197
- (dep) => this.#getScopeInstance(dep, readScope(dep)),
198
- );
199
- const useBunja = getUse(
200
- bunjaInstanceMap,
201
- (dep) => this.#bakingContext!.currentBunja.addParent(dep),
202
- (dep) => {
203
- if (dep.baked) {
204
- for (const scope of dep.relatedScopes) useScope(scope);
205
- }
206
- return this.#getBunjaInstance(dep, scopeInstanceMap);
207
- },
626
+ const resolved = this.#resolveBunjaRef(
627
+ graphRef,
628
+ frame.readScope,
629
+ frame.inProgressBunjas,
630
+ bunjaRef.seed,
208
631
  );
209
- bunjaFn.use = <T>(dep: Dep<T>) => {
210
- if (dep instanceof Bunja) return useBunja(dep) as T;
211
- if (dep instanceof Scope) return useScope(dep) as T;
212
- throw new Error("`bunja.use` can only be used with Bunja or Scope.");
632
+ frame.activeDependencyIds.add(resolved.instance.id);
633
+ frame.activeDependencyRecipes.push({
634
+ ref: graphRef,
635
+ seed: bunjaRef.seed,
636
+ });
637
+ if (!frame.activeDependencyMounts.has(resolved.instance.id)) {
638
+ frame.activeDependencyMounts.set(resolved.instance.id, resolved.mount);
639
+ }
640
+ frame.activeDependencyDeps.push(...resolved.deps);
641
+ return resolved.value;
642
+ }
643
+ #resolveActiveDependencyRecipe(
644
+ recipe: BunjaInstanceRecipe,
645
+ readScope: ReadScope,
646
+ inProgressBunjas: Set<AnyBunja>,
647
+ ): ResolvedActiveDependencyRecipe {
648
+ const activeDependencyIds = new Set<string>();
649
+ const deps: ScopeInstance[] = [];
650
+ for (const { ref, seed } of recipe.activeDependencies) {
651
+ const resolved = this.#resolveBunjaRef(
652
+ ref,
653
+ readScope,
654
+ inProgressBunjas,
655
+ seed,
656
+ );
657
+ activeDependencyIds.add(resolved.instance.id);
658
+ deps.push(...resolved.deps);
659
+ }
660
+ return {
661
+ activeDependencyIds,
662
+ deps: dedupeScopeInstances(deps),
213
663
  };
214
- const originalBakingContext = this.#bakingContext;
664
+ }
665
+ #prebakeBunjaRef<T, Seed>(
666
+ bunjaRef: NormalizedBunjaRef<T, Seed>,
667
+ readScope: ReadScope,
668
+ prebakeContext: BunjaPrebakeContext,
669
+ ): T {
670
+ const { bunja } = bunjaRef;
671
+ if (prebakeContext.values.has(bunja)) {
672
+ return prebakeContext.values.get(bunja) as T;
673
+ }
674
+ const { inProgressBunjas } = prebakeContext;
675
+ if (inProgressBunjas.has(bunja)) {
676
+ throw new Error("Circular bunja dependency detected.");
677
+ }
678
+ const resolvedReadScope = bunjaRef.scopeValuePairs.length > 0
679
+ ? createReadScopeFn(bunjaRef.scopeValuePairs, readScope)
680
+ : readScope;
681
+ inProgressBunjas.add(bunja);
215
682
  try {
216
- this.#bakingContext = { currentBunja: bunja };
217
- const bunjaInstance = this.#getBunjaInstance(bunja, scopeInstanceMap);
218
- return { bunjaInstance, bunjaInstanceMap, scopeInstanceMap };
683
+ return this.wrapInstance((dispose) => {
684
+ try {
685
+ const frame = this.#createPrebakeFrame(
686
+ bunja,
687
+ resolvedReadScope,
688
+ prebakeContext,
689
+ );
690
+ const value = runWithFrame(
691
+ frame,
692
+ () => bunja.init(bunja.defaultSeed),
693
+ );
694
+ if (!bunja.baked) bunja.bake();
695
+ for (
696
+ const ref of [
697
+ ...bunja.requiredBunjaRefs,
698
+ ...bunja.optionalBunjaRefs,
699
+ ]
700
+ ) this.#prebakeBunjaRef(ref, resolvedReadScope, prebakeContext);
701
+ prebakeContext.values.set(bunja, value);
702
+ return value;
703
+ } finally {
704
+ dispose();
705
+ }
706
+ });
219
707
  } finally {
220
- this.#bakingContext = originalBakingContext;
708
+ inProgressBunjas.delete(bunja);
709
+ }
710
+ }
711
+ #createPrebakeFrame(
712
+ currentBunja: AnyBunja,
713
+ readScope: ReadScope,
714
+ prebakeContext: BunjaPrebakeContext,
715
+ ): BunjaPrebakeFrame {
716
+ const frame = {
717
+ currentBunja,
718
+ readScope,
719
+ prebakeContext,
720
+ use: ((dep: unknown, scopeValuePairs?: ScopeValuePairs) => {
721
+ if (dep instanceof Scope) {
722
+ if (!currentBunja.baked) {
723
+ currentBunja.addScope(dep as Scope<unknown>);
724
+ }
725
+ return readScope(dep as Scope<unknown>);
726
+ }
727
+ if (dep instanceof Bunja || isBunjaRef(dep)) {
728
+ const bunjaRef = normalizeBunjaRuntimeRef(dep, scopeValuePairs);
729
+ return this.#prebakeBunjaDependencyInFrame(
730
+ frame,
731
+ toBunjaGraphRef(bunjaRef),
732
+ "required",
733
+ );
734
+ }
735
+ throw new Error("`bunja.use` can only be used with Bunja or Scope.");
736
+ }) as BunjaUseFn,
737
+ will: ((dep: unknown, scopeValuePairs?: ScopeValuePairs) => {
738
+ if (!(dep instanceof Bunja || isBunjaRef(dep))) {
739
+ throw new Error("`bunja.will` can only be used with Bunja.");
740
+ }
741
+ const bunjaRef = toBunjaGraphRef(
742
+ normalizeBunjaRuntimeRef(dep, scopeValuePairs),
743
+ );
744
+ currentBunja.addOptionalBunjaRef(bunjaRef);
745
+ const value = this.#prebakeBunjaRef(
746
+ bunjaRef,
747
+ readScope,
748
+ prebakeContext,
749
+ );
750
+ return () => {
751
+ if (frameStack[frameStack.length - 1] !== frame) {
752
+ throw new Error(
753
+ "A thunk returned by `bunja.will` can only be called inside the same bunja init function.",
754
+ );
755
+ }
756
+ return value;
757
+ };
758
+ }) as BunjaWillFn,
759
+ effect: noop,
760
+ } satisfies BunjaPrebakeFrame;
761
+ return frame;
762
+ }
763
+ #prebakeBunjaDependencyInFrame<T, Seed>(
764
+ frame: BunjaPrebakeFrame,
765
+ bunjaRef: NormalizedBunjaRef<T, Seed>,
766
+ edge: BunjaDependencyEdge,
767
+ ): T {
768
+ if (edge === "optional" || bunjaRef.scopeValuePairs.length > 0) {
769
+ frame.currentBunja.addOptionalBunjaRef(bunjaRef);
770
+ } else if (edge === "required") {
771
+ frame.currentBunja.addRequiredBunjaRef(bunjaRef);
221
772
  }
773
+ return this.#prebakeBunjaRef(
774
+ bunjaRef,
775
+ frame.readScope,
776
+ frame.prebakeContext,
777
+ );
222
778
  }
223
- #getBunjaInstance<T>(
224
- bunja: Bunja<T>,
779
+ #resolveScopeInstanceMap(
780
+ bunja: AnyBunja,
781
+ readScope: ReadScope,
782
+ ): ScopeInstanceMap {
783
+ const scopeInstanceMap: ScopeInstanceMap = new Map();
784
+ this.#ensureRequiredScopeInstances(bunja, scopeInstanceMap, readScope);
785
+ return scopeInstanceMap;
786
+ }
787
+ #ensureRequiredScopeInstances(
788
+ bunja: AnyBunja,
225
789
  scopeInstanceMap: ScopeInstanceMap,
226
- ): BunjaInstance {
227
- const originalEffect = bunjaFn.effect;
228
- const originalFork = bunjaFn.fork;
229
- const prevBunja = this.#bakingContext?.currentBunja;
230
- try {
231
- const effects: BunjaEffectCallback[] = [];
232
- bunjaFn.effect = (callback: BunjaEffectCallback) => {
233
- effects.push(callback);
234
- };
235
- bunjaFn.fork = (b, scopeValuePairs) => {
236
- const readScope = createReadScopeFn(scopeValuePairs, bunjaFn.use);
237
- const { value, mount } = this.get(b, readScope);
238
- bunjaFn.effect(mount);
239
- return value;
240
- };
241
- if (this.#bakingContext) this.#bakingContext.currentBunja = bunja;
242
- if (bunja.baked) {
243
- const id = bunja.calcInstanceId(scopeInstanceMap);
244
- if (id in this.#bunjas) return this.#bunjas[id];
245
- return this.wrapInstance((dispose) => {
246
- const value = bunja.init();
247
- return this.#createBunjaInstance(id, value, effects, dispose);
248
- });
249
- } else {
250
- return this.wrapInstance((dispose) => {
251
- const value = bunja.init();
252
- bunja.bake();
253
- const id = bunja.calcInstanceId(scopeInstanceMap);
254
- return this.#createBunjaInstance(id, value, effects, dispose);
255
- });
256
- }
257
- } finally {
258
- bunjaFn.effect = originalEffect;
259
- bunjaFn.fork = originalFork;
260
- if (this.#bakingContext) this.#bakingContext.currentBunja = prevBunja!;
790
+ readScope: ReadScope,
791
+ ): void {
792
+ for (const scope of bunja.requiredScopes) {
793
+ if (scopeInstanceMap.has(scope)) continue;
794
+ scopeInstanceMap.set(
795
+ scope,
796
+ this.#getScopeInstance(scope, readScope(scope)),
797
+ );
261
798
  }
262
799
  }
263
800
  #getScopeInstance(scope: Scope<unknown>, value: unknown): ScopeInstance {
@@ -281,27 +818,38 @@ export class BunjaStore {
281
818
  }
282
819
  #createBunjaInstance(
283
820
  id: string,
821
+ baseId: string,
284
822
  value: unknown,
823
+ dependencyMounts: (() => () => void)[],
285
824
  effects: BunjaEffectCallback[],
825
+ recipe: BunjaInstanceRecipe,
286
826
  dispose: () => void,
287
827
  ): BunjaInstance {
288
- const effect = () => {
289
- const cleanups = effects
290
- .map((effect) => effect())
291
- .filter(Boolean) as (() => void)[];
292
- return () => cleanups.forEach((cleanup) => cleanup());
293
- };
294
- const bunjaInstance = new BunjaInstance(id, value, effect, () => {
295
- if (__DEV__) {
296
- devtoolsGlobalHook.emit("bunjaInstanceUnmounted", {
297
- storeId: this.id,
298
- bunjaInstanceId: id,
299
- });
300
- }
301
- dispose();
302
- delete this.#bunjas[id];
303
- });
828
+ const bunjaInstance = new BunjaInstance(
829
+ id,
830
+ baseId,
831
+ value,
832
+ dependencyMounts,
833
+ effects,
834
+ recipe,
835
+ () => {
836
+ if (__DEV__) {
837
+ devtoolsGlobalHook.emit("bunjaInstanceUnmounted", {
838
+ storeId: this.id,
839
+ bunjaInstanceId: id,
840
+ });
841
+ }
842
+ dispose();
843
+ delete this.#bunjas[id];
844
+ const bucket = this.#bunjaBuckets.get(baseId);
845
+ bucket?.delete(id);
846
+ if (bucket?.size === 0) this.#bunjaBuckets.delete(baseId);
847
+ },
848
+ );
304
849
  this.#bunjas[id] = bunjaInstance;
850
+ const bucket = this.#bunjaBuckets.get(baseId) ??
851
+ this.#bunjaBuckets.set(baseId, new Set()).get(baseId)!;
852
+ bucket.add(id);
305
853
  if (__DEV__) {
306
854
  devtoolsGlobalHook.emit("bunjaInstanceMounted", {
307
855
  storeId: this.id,
@@ -348,6 +896,11 @@ export interface BunjaStoreGetResult<T> {
348
896
  bunjaInstance?: BunjaInstance;
349
897
  }
350
898
 
899
+ export interface BunjaStorePrebakeResult {
900
+ relatedBunjas: Bunja<any, any>[];
901
+ requiredScopes: Scope<unknown>[];
902
+ }
903
+
351
904
  export function delayUnmount(
352
905
  mount: () => () => void,
353
906
  ms: number = 0,
@@ -358,12 +911,20 @@ export function delayUnmount(
358
911
  };
359
912
  }
360
913
 
361
- export class Bunja<T> {
914
+ export class Bunja<T, Seed = NoSeed> {
362
915
  private static counter: number = 0;
363
916
  readonly id: string = String(Bunja.counter++);
364
917
  debugLabel: string = "";
365
- #phase: BunjaPhase = { baked: false, parents: new Set(), scopes: new Set() };
366
- constructor(public init: () => T) {
918
+ #phase: BunjaPhase = {
919
+ baked: false,
920
+ requiredBunjaRefs: [],
921
+ optionalBunjaRefs: [],
922
+ scopes: new Set(),
923
+ };
924
+ constructor(
925
+ public init: (seed: Seed) => T,
926
+ public defaultSeed: Seed,
927
+ ) {
367
928
  if (__DEV__) {
368
929
  devtoolsGlobalHook.bunjas[this.id] = this;
369
930
  devtoolsGlobalHook.emit("bunjaCreated", { bunjaId: this.id });
@@ -372,21 +933,44 @@ export class Bunja<T> {
372
933
  get baked(): boolean {
373
934
  return this.#phase.baked;
374
935
  }
375
- get parents(): Bunja<unknown>[] {
376
- if (this.#phase.baked) return this.#phase.parents;
377
- return Array.from(this.#phase.parents);
936
+ get requiredBunjas(): AnyBunja[] {
937
+ return dedupeBunjas(
938
+ this.#phase.requiredBunjaRefs.map(({ bunja }) => bunja),
939
+ );
940
+ }
941
+ get optionalBunjas(): AnyBunja[] {
942
+ return dedupeBunjas(
943
+ this.#phase.optionalBunjaRefs.map(({ bunja }) => bunja),
944
+ );
378
945
  }
379
- get relatedBunjas(): Bunja<unknown>[] {
946
+ get requiredBunjaRefs(): AnyNormalizedBunjaRef[] {
947
+ return this.#phase.requiredBunjaRefs;
948
+ }
949
+ get optionalBunjaRefs(): AnyNormalizedBunjaRef[] {
950
+ return this.#phase.optionalBunjaRefs;
951
+ }
952
+ get expandedRequiredBunjas(): AnyBunja[] {
380
953
  if (!this.#phase.baked) throw new Error("Bunja is not baked yet.");
381
- return this.#phase.relatedBunjas;
954
+ return this.#phase.expandedRequiredBunjas;
382
955
  }
383
- get relatedScopes(): Scope<unknown>[] {
956
+ get relatedBunjas(): AnyBunja[] {
384
957
  if (!this.#phase.baked) throw new Error("Bunja is not baked yet.");
385
- return this.#phase.relatedScopes;
958
+ return toposortRelatedBunjas([
959
+ ...this.requiredBunjas,
960
+ ...this.optionalBunjas,
961
+ ]);
386
962
  }
387
- addParent(bunja: Bunja<unknown>): void {
963
+ get requiredScopes(): Scope<unknown>[] {
964
+ if (!this.#phase.baked) throw new Error("Bunja is not baked yet.");
965
+ return this.#phase.requiredScopes;
966
+ }
967
+ addRequiredBunjaRef(ref: AnyNormalizedBunjaRef): void {
388
968
  if (this.#phase.baked) return;
389
- this.#phase.parents.add(bunja);
969
+ addUniqueBunjaRef(this.#phase.requiredBunjaRefs, ref);
970
+ }
971
+ addOptionalBunjaRef(ref: AnyNormalizedBunjaRef): void {
972
+ if (this.#phase.baked) return;
973
+ addUniqueBunjaRef(this.#phase.optionalBunjaRefs, ref);
390
974
  }
391
975
  addScope(scope: Scope<unknown>): void {
392
976
  if (this.#phase.baked) return;
@@ -395,22 +979,40 @@ export class Bunja<T> {
395
979
  bake(): void {
396
980
  if (this.#phase.baked) throw new Error("Bunja is already baked.");
397
981
  const scopes = this.#phase.scopes;
398
- const parents = this.parents;
399
- const relatedBunjas = toposort(parents);
400
- const relatedScopes = Array.from(
401
- new Set([
402
- ...relatedBunjas.flatMap((bunja) => bunja.relatedScopes),
403
- ...scopes,
404
- ]),
405
- );
406
- this.#phase = { baked: true, parents, relatedBunjas, relatedScopes };
982
+ const requiredBunjaRefs = this.#phase.requiredBunjaRefs;
983
+ const optionalBunjaRefs = this.#phase.optionalBunjaRefs;
984
+ const requiredBunjas = this.requiredBunjas;
985
+ const expandedRequiredBunjas = toposortRequiredBunjas(requiredBunjas);
986
+ const requiredScopeSet = new Set<Scope<unknown>>();
987
+ for (const bunja of expandedRequiredBunjas) {
988
+ for (const scope of bunja.requiredScopes) requiredScopeSet.add(scope);
989
+ }
990
+ for (const scope of scopes) requiredScopeSet.add(scope);
991
+ const requiredScopes = Array.from(requiredScopeSet);
992
+ this.#phase = {
993
+ baked: true,
994
+ requiredBunjaRefs,
995
+ optionalBunjaRefs,
996
+ expandedRequiredBunjas,
997
+ requiredScopes,
998
+ };
407
999
  }
408
- calcInstanceId(scopeInstanceMap: Map<Scope<unknown>, ScopeInstance>): string {
409
- const scopeInstanceIds = this.relatedScopes.map(
1000
+ calcBaseInstanceId(
1001
+ scopeInstanceMap: Map<Scope<unknown>, ScopeInstance>,
1002
+ ): string {
1003
+ const scopeInstanceIds = this.requiredScopes.map(
410
1004
  (scope) => scopeInstanceMap.get(scope)!.id,
411
1005
  );
412
1006
  return `${this.id}:${scopeInstanceIds.join(",")}`;
413
1007
  }
1008
+ calcInstanceId(
1009
+ scopeInstanceMap: Map<Scope<unknown>, ScopeInstance>,
1010
+ activeDependencyIds: Iterable<string> = [],
1011
+ ): string {
1012
+ return `${this.calcBaseInstanceId(scopeInstanceMap)}:${
1013
+ Array.from(activeDependencyIds).join(",")
1014
+ }`;
1015
+ }
414
1016
  toString(): string {
415
1017
  const { id, debugLabel } = this;
416
1018
  return `[Bunja:${id}${debugLabel && ` - ${debugLabel}`}]`;
@@ -421,15 +1023,17 @@ type BunjaPhase = BunjaPhaseUnbaked | BunjaPhaseBaked;
421
1023
 
422
1024
  interface BunjaPhaseUnbaked {
423
1025
  readonly baked: false;
424
- readonly parents: Set<Bunja<unknown>>;
1026
+ readonly requiredBunjaRefs: AnyNormalizedBunjaRef[];
1027
+ readonly optionalBunjaRefs: AnyNormalizedBunjaRef[];
425
1028
  readonly scopes: Set<Scope<unknown>>;
426
1029
  }
427
1030
 
428
1031
  interface BunjaPhaseBaked {
429
1032
  readonly baked: true;
430
- readonly parents: Bunja<unknown>[];
431
- readonly relatedBunjas: Bunja<unknown>[];
432
- readonly relatedScopes: Scope<unknown>[];
1033
+ readonly requiredBunjaRefs: AnyNormalizedBunjaRef[];
1034
+ readonly optionalBunjaRefs: AnyNormalizedBunjaRef[];
1035
+ readonly expandedRequiredBunjas: AnyBunja[];
1036
+ readonly requiredScopes: Scope<unknown>[];
433
1037
  }
434
1038
 
435
1039
  export class Scope<T> {
@@ -474,22 +1078,39 @@ abstract class RefCounter {
474
1078
 
475
1079
  class BunjaInstance extends RefCounter {
476
1080
  #cleanup: (() => void) | undefined;
1081
+ #disposed = false;
477
1082
  constructor(
478
1083
  public readonly id: string,
1084
+ public readonly baseId: string,
479
1085
  public readonly value: unknown,
480
- public readonly effect: BunjaEffectCallback,
1086
+ private readonly dependencyMounts: (() => () => void)[],
1087
+ private readonly effects: BunjaEffectCallback[],
1088
+ public readonly recipe: BunjaInstanceRecipe,
481
1089
  private readonly _dispose: () => void,
482
1090
  ) {
483
1091
  super();
484
1092
  }
485
1093
  override dispose(): void {
1094
+ if (this.#disposed) return;
1095
+ this.#disposed = true;
486
1096
  this.#cleanup?.();
1097
+ this.#cleanup = undefined;
487
1098
  this._dispose();
488
1099
  }
489
1100
  override add(): void {
490
- this.#cleanup ??= this.effect() ?? noop;
1101
+ this.#cleanup ??= this.#mount();
491
1102
  super.add();
492
1103
  }
1104
+ #mount(): () => void {
1105
+ const dependencyCleanups = this.dependencyMounts.map((mount) => mount());
1106
+ const effectCleanups = this.effects
1107
+ .map((effect) => effect())
1108
+ .filter(Boolean) as (() => void)[];
1109
+ return () => {
1110
+ for (const cleanup of dependencyCleanups) cleanup();
1111
+ for (const cleanup of effectCleanups) cleanup();
1112
+ };
1113
+ }
493
1114
  }
494
1115
 
495
1116
  class ScopeInstance extends RefCounter {
@@ -503,28 +1124,33 @@ class ScopeInstance extends RefCounter {
503
1124
  }
504
1125
  }
505
1126
 
506
- interface Toposortable {
507
- parents: Toposortable[];
508
- }
509
- function toposort<T extends Toposortable>(nodes: T[]): T[] {
1127
+ function toposort<T>(nodes: T[], getDependencies: (node: T) => T[]): T[] {
510
1128
  const visited = new Set<T>();
511
1129
  const result: T[] = [];
512
1130
  function visit(current: T) {
513
1131
  if (visited.has(current)) return;
514
1132
  visited.add(current);
515
- for (const parent of current.parents) visit(parent as T);
1133
+ for (const dependency of getDependencies(current)) visit(dependency);
516
1134
  result.push(current);
517
1135
  }
518
1136
  for (const node of nodes) visit(node);
519
1137
  return result;
520
1138
  }
1139
+ function toposortRequiredBunjas(bunjas: AnyBunja[]): AnyBunja[] {
1140
+ return toposort(bunjas, (bunja) => bunja.requiredBunjas);
1141
+ }
1142
+ function toposortRelatedBunjas(bunjas: AnyBunja[]): AnyBunja[] {
1143
+ return toposort(bunjas, (bunja) => [
1144
+ ...bunja.requiredBunjas,
1145
+ ...bunja.optionalBunjas,
1146
+ ]);
1147
+ }
521
1148
 
522
1149
  const noop = () => {};
523
1150
 
524
1151
  export interface BunjaDevtoolsGlobalHook {
525
- bunjas: Record<string, Bunja<any>>;
1152
+ bunjas: Record<string, Bunja<any, any>>;
526
1153
  scopes: Record<string, Scope<any>>;
527
- stores: Record<string, BunjaStore>;
528
1154
  listeners: Record<
529
1155
  BunjaDevtoolsEventType,
530
1156
  Set<(event: any) => void>
@@ -564,7 +1190,6 @@ if (__DEV__) {
564
1190
  devtoolsGlobalHook = (globalThis as any).__BUNJA_DEVTOOLS_GLOBAL_HOOK__;
565
1191
  } else {
566
1192
  devtoolsGlobalHook = {
567
- stores: {},
568
1193
  bunjas: {},
569
1194
  scopes: {},
570
1195
  listeners: {