bunja 2.1.1 → 3.0.0-alpha.4

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