bunja 2.1.1 → 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,19 +126,175 @@ 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
300
  if (__DEV__) devtoolsGlobalHook.emit("storeCreated", { storeId: this.id });
@@ -94,7 +305,7 @@ export class BunjaStore {
94
305
  bunjas: this.#bunjas,
95
306
  scopes: this.#scopes,
96
307
  get instantiating() {
97
- return bunja.use != invalidUse;
308
+ return frameStack.length > 0;
98
309
  },
99
310
  };
100
311
  }
@@ -102,156 +313,488 @@ export class BunjaStore {
102
313
  }
103
314
  dispose(): void {
104
315
  for (const instance of Object.values(this.#bunjas)) instance.dispose();
105
- for (const instanceMap of Object.values(this.#scopes)) {
316
+ for (const instanceMap of this.#scopes.values()) {
106
317
  for (const instance of instanceMap.values()) instance.dispose();
107
318
  }
108
319
  this.#bunjas = {};
320
+ this.#bunjaBuckets = new Map();
109
321
  this.#scopes = new Map();
110
322
  if (__DEV__) devtoolsGlobalHook.emit("storeDisposed", { storeId: this.id });
111
323
  }
112
- get<T>(bunja: Bunja<T>, readScope: ReadScope): BunjaStoreGetResult<T> {
113
- const originalUse = bunjaFn.use;
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
+ };
340
+ if (__DEV__) {
341
+ result.bunjaInstance = resolved.instance;
342
+ devtoolsGlobalHook.emit("getCalled", {
343
+ storeId: this.id,
344
+ bunjaInstanceId: resolved.instance.id,
345
+ });
346
+ }
347
+ return result;
348
+ }
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);
114
378
  try {
115
- const { bunjaInstance, bunjaInstanceMap, scopeInstanceMap } = bunja.baked
116
- ? this.#getBaked(bunja, readScope)
117
- : this.#getUnbaked(bunja, readScope);
118
- const result: BunjaStoreGetResult<T> = {
119
- value: bunjaInstance.value as T,
120
- mount: () => {
121
- bunjaInstanceMap.forEach((instance) => instance.add());
122
- bunjaInstance.add();
123
- scopeInstanceMap.forEach((instance) => instance.add());
124
- const unmount = () => {
125
- bunjaInstanceMap.forEach((instance) => instance.sub());
126
- bunjaInstance.sub();
127
- scopeInstanceMap.forEach((instance) => instance.sub());
128
- };
129
- return unmount;
130
- },
131
- deps: Array.from(scopeInstanceMap.values()).map(({ value }) => value),
132
- };
133
- if (__DEV__) {
134
- result.bunjaInstance = bunjaInstance;
135
- devtoolsGlobalHook.emit("getCalled", {
136
- storeId: this.id,
137
- bunjaInstanceId: bunjaInstance.id,
138
- });
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
+ }
139
430
  }
140
- return result;
431
+ return this.#createResolvedBunja(
432
+ bunjaRef,
433
+ resolvedReadScope,
434
+ inProgressBunjas,
435
+ seed,
436
+ scopeInstanceMap,
437
+ );
141
438
  } finally {
142
- bunjaFn.use = originalUse;
439
+ inProgressBunjas.delete(bunja);
143
440
  }
144
441
  }
145
- #getBaked<T>(bunja: Bunja<T>, readScope: ReadScope): BunjaStoreGetContext {
146
- const scopeInstanceMap = new Map(
147
- bunja.relatedScopes.map((scope) => [
148
- scope,
149
- this.#getScopeInstance(scope, readScope(scope)),
150
- ]),
151
- );
152
- const bunjaInstanceMap = new Map();
153
- bunjaFn.use = <T>(dep: Dep<T>) => {
154
- if (dep instanceof Bunja) {
155
- return bunjaInstanceMap.get(dep as Bunja<unknown>)!.value as T;
156
- }
157
- if (dep instanceof Scope) {
158
- 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();
159
515
  }
160
- 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
+ },
161
540
  };
162
- for (const relatedBunja of bunja.relatedBunjas) {
163
- bunjaInstanceMap.set(
164
- relatedBunja,
165
- 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),
166
610
  );
611
+ frame.scopeInstanceMap.set(scope as Scope<unknown>, scopeInstance);
167
612
  }
168
- const bunjaInstance = this.#getBunjaInstance(bunja, scopeInstanceMap);
169
- return { bunjaInstance, bunjaInstanceMap, scopeInstanceMap };
613
+ return scopeInstance.value as T;
170
614
  }
171
- #getUnbaked<T>(bunja: Bunja<T>, readScope: ReadScope): BunjaStoreGetContext {
172
- const bunjaInstanceMap: BunjaInstanceMap = new Map();
173
- const scopeInstanceMap: ScopeInstanceMap = new Map();
174
- function getUse<D extends Dep<unknown>, I extends { value: unknown }>(
175
- map: Map<D, I>,
176
- addDep: (D: D) => void,
177
- getInstance: (dep: D) => I,
178
- ) {
179
- return ((dep) => {
180
- const d = dep as D;
181
- addDep(d);
182
- if (map.has(d)) return map.get(d)!.value as T;
183
- const instance = getInstance(d);
184
- map.set(d, instance);
185
- return instance.value as T;
186
- }) 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);
187
625
  }
188
- const useScope = getUse(
189
- scopeInstanceMap,
190
- (dep) => this.#bakingContext!.currentBunja.addScope(dep),
191
- (dep) => this.#getScopeInstance(dep, readScope(dep)),
192
- );
193
- const useBunja = getUse(
194
- bunjaInstanceMap,
195
- (dep) => this.#bakingContext!.currentBunja.addParent(dep),
196
- (dep) => {
197
- if (dep.baked) {
198
- for (const scope of dep.relatedScopes) useScope(scope);
199
- }
200
- return this.#getBunjaInstance(dep, scopeInstanceMap);
201
- },
626
+ const resolved = this.#resolveBunjaRef(
627
+ graphRef,
628
+ frame.readScope,
629
+ frame.inProgressBunjas,
630
+ bunjaRef.seed,
202
631
  );
203
- bunjaFn.use = <T>(dep: Dep<T>) => {
204
- if (dep instanceof Bunja) return useBunja(dep) as T;
205
- if (dep instanceof Scope) return useScope(dep) as T;
206
- 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),
207
663
  };
208
- 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);
209
682
  try {
210
- this.#bakingContext = { currentBunja: bunja };
211
- const bunjaInstance = this.#getBunjaInstance(bunja, scopeInstanceMap);
212
- 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
+ });
213
707
  } finally {
214
- 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);
215
772
  }
773
+ return this.#prebakeBunjaRef(
774
+ bunjaRef,
775
+ frame.readScope,
776
+ frame.prebakeContext,
777
+ );
216
778
  }
217
- #getBunjaInstance<T>(
218
- 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,
219
789
  scopeInstanceMap: ScopeInstanceMap,
220
- ): BunjaInstance {
221
- const originalEffect = bunjaFn.effect;
222
- const originalFork = bunjaFn.fork;
223
- const prevBunja = this.#bakingContext?.currentBunja;
224
- try {
225
- const effects: BunjaEffectCallback[] = [];
226
- bunjaFn.effect = (callback: BunjaEffectCallback) => {
227
- effects.push(callback);
228
- };
229
- bunjaFn.fork = (b, scopeValuePairs) => {
230
- const readScope = createReadScopeFn(scopeValuePairs, bunjaFn.use);
231
- const { value, mount } = this.get(b, readScope);
232
- bunjaFn.effect(mount);
233
- return value;
234
- };
235
- if (this.#bakingContext) this.#bakingContext.currentBunja = bunja;
236
- if (bunja.baked) {
237
- const id = bunja.calcInstanceId(scopeInstanceMap);
238
- if (id in this.#bunjas) return this.#bunjas[id];
239
- return this.wrapInstance((dispose) => {
240
- const value = bunja.init();
241
- return this.#createBunjaInstance(id, value, effects, dispose);
242
- });
243
- } else {
244
- return this.wrapInstance((dispose) => {
245
- const value = bunja.init();
246
- bunja.bake();
247
- const id = bunja.calcInstanceId(scopeInstanceMap);
248
- return this.#createBunjaInstance(id, value, effects, dispose);
249
- });
250
- }
251
- } finally {
252
- bunjaFn.effect = originalEffect;
253
- bunjaFn.fork = originalFork;
254
- 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
+ );
255
798
  }
256
799
  }
257
800
  #getScopeInstance(scope: Scope<unknown>, value: unknown): ScopeInstance {
@@ -275,27 +818,38 @@ export class BunjaStore {
275
818
  }
276
819
  #createBunjaInstance(
277
820
  id: string,
821
+ baseId: string,
278
822
  value: unknown,
823
+ dependencyMounts: (() => () => void)[],
279
824
  effects: BunjaEffectCallback[],
825
+ recipe: BunjaInstanceRecipe,
280
826
  dispose: () => void,
281
827
  ): BunjaInstance {
282
- const effect = () => {
283
- const cleanups = effects
284
- .map((effect) => effect())
285
- .filter(Boolean) as (() => void)[];
286
- return () => cleanups.forEach((cleanup) => cleanup());
287
- };
288
- const bunjaInstance = new BunjaInstance(id, value, effect, () => {
289
- if (__DEV__) {
290
- devtoolsGlobalHook.emit("bunjaInstanceUnmounted", {
291
- storeId: this.id,
292
- bunjaInstanceId: id,
293
- });
294
- }
295
- dispose();
296
- delete this.#bunjas[id];
297
- });
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
+ );
298
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);
299
853
  if (__DEV__) {
300
854
  devtoolsGlobalHook.emit("bunjaInstanceMounted", {
301
855
  storeId: this.id,
@@ -342,6 +896,11 @@ export interface BunjaStoreGetResult<T> {
342
896
  bunjaInstance?: BunjaInstance;
343
897
  }
344
898
 
899
+ export interface BunjaStorePrebakeResult {
900
+ relatedBunjas: Bunja<any, any>[];
901
+ requiredScopes: Scope<unknown>[];
902
+ }
903
+
345
904
  export function delayUnmount(
346
905
  mount: () => () => void,
347
906
  ms: number = 0,
@@ -352,12 +911,20 @@ export function delayUnmount(
352
911
  };
353
912
  }
354
913
 
355
- export class Bunja<T> {
914
+ export class Bunja<T, Seed = NoSeed> {
356
915
  private static counter: number = 0;
357
916
  readonly id: string = String(Bunja.counter++);
358
917
  debugLabel: string = "";
359
- #phase: BunjaPhase = { baked: false, parents: new Set(), scopes: new Set() };
360
- 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
+ ) {
361
928
  if (__DEV__) {
362
929
  devtoolsGlobalHook.bunjas[this.id] = this;
363
930
  devtoolsGlobalHook.emit("bunjaCreated", { bunjaId: this.id });
@@ -366,21 +933,44 @@ export class Bunja<T> {
366
933
  get baked(): boolean {
367
934
  return this.#phase.baked;
368
935
  }
369
- get parents(): Bunja<unknown>[] {
370
- if (this.#phase.baked) return this.#phase.parents;
371
- 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
+ );
372
945
  }
373
- 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[] {
374
953
  if (!this.#phase.baked) throw new Error("Bunja is not baked yet.");
375
- return this.#phase.relatedBunjas;
954
+ return this.#phase.expandedRequiredBunjas;
376
955
  }
377
- get relatedScopes(): Scope<unknown>[] {
956
+ get relatedBunjas(): AnyBunja[] {
378
957
  if (!this.#phase.baked) throw new Error("Bunja is not baked yet.");
379
- return this.#phase.relatedScopes;
958
+ return toposortRelatedBunjas([
959
+ ...this.requiredBunjas,
960
+ ...this.optionalBunjas,
961
+ ]);
380
962
  }
381
- 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 {
382
968
  if (this.#phase.baked) return;
383
- 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);
384
974
  }
385
975
  addScope(scope: Scope<unknown>): void {
386
976
  if (this.#phase.baked) return;
@@ -389,22 +979,40 @@ export class Bunja<T> {
389
979
  bake(): void {
390
980
  if (this.#phase.baked) throw new Error("Bunja is already baked.");
391
981
  const scopes = this.#phase.scopes;
392
- const parents = this.parents;
393
- const relatedBunjas = toposort(parents);
394
- const relatedScopes = Array.from(
395
- new Set([
396
- ...relatedBunjas.flatMap((bunja) => bunja.relatedScopes),
397
- ...scopes,
398
- ]),
399
- );
400
- 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
+ };
401
999
  }
402
- calcInstanceId(scopeInstanceMap: Map<Scope<unknown>, ScopeInstance>): string {
403
- const scopeInstanceIds = this.relatedScopes.map(
1000
+ calcBaseInstanceId(
1001
+ scopeInstanceMap: Map<Scope<unknown>, ScopeInstance>,
1002
+ ): string {
1003
+ const scopeInstanceIds = this.requiredScopes.map(
404
1004
  (scope) => scopeInstanceMap.get(scope)!.id,
405
1005
  );
406
1006
  return `${this.id}:${scopeInstanceIds.join(",")}`;
407
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
+ }
408
1016
  toString(): string {
409
1017
  const { id, debugLabel } = this;
410
1018
  return `[Bunja:${id}${debugLabel && ` - ${debugLabel}`}]`;
@@ -415,15 +1023,17 @@ type BunjaPhase = BunjaPhaseUnbaked | BunjaPhaseBaked;
415
1023
 
416
1024
  interface BunjaPhaseUnbaked {
417
1025
  readonly baked: false;
418
- readonly parents: Set<Bunja<unknown>>;
1026
+ readonly requiredBunjaRefs: AnyNormalizedBunjaRef[];
1027
+ readonly optionalBunjaRefs: AnyNormalizedBunjaRef[];
419
1028
  readonly scopes: Set<Scope<unknown>>;
420
1029
  }
421
1030
 
422
1031
  interface BunjaPhaseBaked {
423
1032
  readonly baked: true;
424
- readonly parents: Bunja<unknown>[];
425
- readonly relatedBunjas: Bunja<unknown>[];
426
- readonly relatedScopes: Scope<unknown>[];
1033
+ readonly requiredBunjaRefs: AnyNormalizedBunjaRef[];
1034
+ readonly optionalBunjaRefs: AnyNormalizedBunjaRef[];
1035
+ readonly expandedRequiredBunjas: AnyBunja[];
1036
+ readonly requiredScopes: Scope<unknown>[];
427
1037
  }
428
1038
 
429
1039
  export class Scope<T> {
@@ -468,22 +1078,39 @@ abstract class RefCounter {
468
1078
 
469
1079
  class BunjaInstance extends RefCounter {
470
1080
  #cleanup: (() => void) | undefined;
1081
+ #disposed = false;
471
1082
  constructor(
472
1083
  public readonly id: string,
1084
+ public readonly baseId: string,
473
1085
  public readonly value: unknown,
474
- public readonly effect: BunjaEffectCallback,
1086
+ private readonly dependencyMounts: (() => () => void)[],
1087
+ private readonly effects: BunjaEffectCallback[],
1088
+ public readonly recipe: BunjaInstanceRecipe,
475
1089
  private readonly _dispose: () => void,
476
1090
  ) {
477
1091
  super();
478
1092
  }
479
1093
  override dispose(): void {
1094
+ if (this.#disposed) return;
1095
+ this.#disposed = true;
480
1096
  this.#cleanup?.();
1097
+ this.#cleanup = undefined;
481
1098
  this._dispose();
482
1099
  }
483
1100
  override add(): void {
484
- this.#cleanup ??= this.effect() ?? noop;
1101
+ this.#cleanup ??= this.#mount();
485
1102
  super.add();
486
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
+ }
487
1114
  }
488
1115
 
489
1116
  class ScopeInstance extends RefCounter {
@@ -497,26 +1124,32 @@ class ScopeInstance extends RefCounter {
497
1124
  }
498
1125
  }
499
1126
 
500
- interface Toposortable {
501
- parents: Toposortable[];
502
- }
503
- function toposort<T extends Toposortable>(nodes: T[]): T[] {
1127
+ function toposort<T>(nodes: T[], getDependencies: (node: T) => T[]): T[] {
504
1128
  const visited = new Set<T>();
505
1129
  const result: T[] = [];
506
1130
  function visit(current: T) {
507
1131
  if (visited.has(current)) return;
508
1132
  visited.add(current);
509
- for (const parent of current.parents) visit(parent as T);
1133
+ for (const dependency of getDependencies(current)) visit(dependency);
510
1134
  result.push(current);
511
1135
  }
512
1136
  for (const node of nodes) visit(node);
513
1137
  return result;
514
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
+ }
515
1148
 
516
1149
  const noop = () => {};
517
1150
 
518
1151
  export interface BunjaDevtoolsGlobalHook {
519
- bunjas: Record<string, Bunja<any>>;
1152
+ bunjas: Record<string, Bunja<any, any>>;
520
1153
  scopes: Record<string, Scope<any>>;
521
1154
  listeners: Record<
522
1155
  BunjaDevtoolsEventType,