@pumped-fn/lite 2.0.0 → 2.1.2

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/dist/index.cjs CHANGED
@@ -88,7 +88,7 @@ function getAtomsForTag(tag$1) {
88
88
  return live;
89
89
  }
90
90
  function tag(options) {
91
- const key = Symbol.for(`@pumped-fn/lite/tag/${options.label}`);
91
+ const key = Symbol(`@pumped-fn/lite/tag/${options.label}`);
92
92
  const hasDefault = "default" in options;
93
93
  const defaultValue = hasDefault ? options.default : void 0;
94
94
  const parse = options.parse;
@@ -263,20 +263,33 @@ function isAtom(value) {
263
263
  * The Controller provides full lifecycle control: get, resolve, release, invalidate, and subscribe.
264
264
  *
265
265
  * @param atom - The Atom to wrap
266
- * @param options - Optional configuration. Use { resolve: true } to auto-resolve before factory runs.
266
+ * @param options - Optional configuration:
267
+ * - `resolve: true` — auto-resolves the dep before the parent factory runs; `config.get()` is safe.
268
+ * - `watch: true` — atom deps only; requires `resolve: true`; automatically re-runs the parent factory
269
+ * when the dep resolves to a new value (value-equality gated via `Object.is` by default). Replaces
270
+ * manual `ctx.cleanup(ctx.scope.on('resolved', dep, () => ctx.invalidate()))` wiring. Watch
271
+ * listeners are auto-cleaned on re-resolve, release, and dispose.
272
+ * - `eq` — custom equality function `(a: T, b: T) => boolean`; only used with `watch: true`.
267
273
  * @returns A ControllerDep that resolves to a Controller for the Atom
268
274
  *
269
275
  * @example
270
276
  * ```typescript
271
- * const configAtom = atom({ factory: () => fetchConfig() })
277
+ * // resolve only
272
278
  * const serverAtom = atom({
273
279
  * deps: { config: controller(configAtom, { resolve: true }) },
274
- * factory: (ctx, { config }) => {
275
- * // config.get() is safe - already resolved
276
- * const unsub = config.on('resolved', () => ctx.invalidate())
277
- * ctx.cleanup(unsub)
278
- * return createServer(config.get().port)
279
- * }
280
+ * factory: (_, { config }) => createServer(config.get().port),
281
+ * })
282
+ *
283
+ * // watch: re-runs parent when dep value changes
284
+ * const profileAtom = atom({
285
+ * deps: { token: controller(tokenAtom, { resolve: true, watch: true }) },
286
+ * factory: (_, { token }) => ({ id: `user-${token.get().jwt}` }),
287
+ * })
288
+ *
289
+ * // watch with custom equality
290
+ * const derivedAtom = atom({
291
+ * deps: { src: controller(srcAtom, { resolve: true, watch: true, eq: (a, b) => a.id === b.id }) },
292
+ * factory: (_, { src }) => src.get().name,
280
293
  * })
281
294
  * ```
282
295
  */
@@ -284,7 +297,9 @@ function controller(atom$1, options) {
284
297
  return {
285
298
  [controllerDepSymbol]: true,
286
299
  atom: atom$1,
287
- resolve: options?.resolve
300
+ resolve: options?.resolve,
301
+ watch: options?.watch,
302
+ eq: options?.eq
288
303
  };
289
304
  }
290
305
  /**
@@ -410,12 +425,14 @@ function isResource(value) {
410
425
  //#endregion
411
426
  //#region src/service.ts
412
427
  function service(config) {
413
- return {
428
+ const atomInstance = {
414
429
  [atomSymbol]: true,
415
430
  factory: config.factory,
416
431
  deps: config.deps,
417
432
  tags: config.tags
418
433
  };
434
+ if (config.tags?.length) registerAtomToTags(atomInstance, config.tags);
435
+ return atomInstance;
419
436
  }
420
437
 
421
438
  //#endregion
@@ -431,7 +448,7 @@ function getResourceKey(resource$1) {
431
448
  return key;
432
449
  }
433
450
  const inflightResources = /* @__PURE__ */ new WeakMap();
434
- const resolvingResources = /* @__PURE__ */ new Set();
451
+ const resolvingResourcesMap = /* @__PURE__ */ new WeakMap();
435
452
  var ContextDataImpl = class {
436
453
  map = /* @__PURE__ */ new Map();
437
454
  constructor(parentData) {
@@ -456,6 +473,10 @@ var ContextDataImpl = class {
456
473
  if (this.map.has(key)) return this.map.get(key);
457
474
  return this.parentData?.seek(key);
458
475
  }
476
+ seekHas(key) {
477
+ if (this.map.has(key)) return true;
478
+ return this.parentData?.seekHas(key) ?? false;
479
+ }
459
480
  getTag(tag$1) {
460
481
  return this.map.get(tag$1.key);
461
482
  }
@@ -501,6 +522,16 @@ var SelectHandleImpl = class {
501
522
  return this.currentValue;
502
523
  }
503
524
  subscribe(listener) {
525
+ if (!this.ctrlUnsub) {
526
+ this.currentValue = this.selector(this.ctrl.get());
527
+ this.ctrlUnsub = this.ctrl.on("resolved", () => {
528
+ const nextValue = this.selector(this.ctrl.get());
529
+ if (!this.eq(this.currentValue, nextValue)) {
530
+ this.currentValue = nextValue;
531
+ this.notifyListeners();
532
+ }
533
+ });
534
+ }
504
535
  this.listeners.add(listener);
505
536
  return () => {
506
537
  this.listeners.delete(listener);
@@ -508,12 +539,15 @@ var SelectHandleImpl = class {
508
539
  };
509
540
  }
510
541
  notifyListeners() {
511
- for (const listener of this.listeners) listener();
542
+ for (const listener of [...this.listeners]) listener();
543
+ }
544
+ dispose() {
545
+ this.listeners.clear();
546
+ this.cleanup();
512
547
  }
513
548
  cleanup() {
514
549
  this.ctrlUnsub?.();
515
550
  this.ctrlUnsub = null;
516
- this.listeners.clear();
517
551
  }
518
552
  };
519
553
  var ControllerImpl = class {
@@ -562,11 +596,15 @@ var ScopeImpl = class {
562
596
  invalidationScheduled = false;
563
597
  invalidationChain = null;
564
598
  chainPromise = null;
599
+ chainError = null;
565
600
  initialized = false;
601
+ disposed = false;
566
602
  controllers = /* @__PURE__ */ new Map();
567
603
  gcOptions;
568
604
  extensions;
569
605
  tags;
606
+ resolveExts;
607
+ execExts;
570
608
  ready;
571
609
  scheduleInvalidation(atom$1) {
572
610
  const entry = this.cache.get(atom$1);
@@ -579,16 +617,22 @@ var ScopeImpl = class {
579
617
  if (!this.chainPromise) {
580
618
  this.invalidationChain = /* @__PURE__ */ new Set();
581
619
  this.invalidationScheduled = true;
582
- this.chainPromise = new Promise((resolve, reject) => {
583
- queueMicrotask(() => {
584
- this.processInvalidationChain().then(resolve).catch(reject);
620
+ this.chainError = null;
621
+ this.chainPromise = (async () => {
622
+ await new Promise((resolve) => {
623
+ queueMicrotask(resolve);
585
624
  });
586
- });
625
+ try {
626
+ await this.processInvalidationChain();
627
+ } catch (error) {
628
+ if (this.chainError === null) this.chainError = error;
629
+ }
630
+ })();
587
631
  }
588
632
  }
589
633
  async processInvalidationChain() {
590
634
  try {
591
- while (this.invalidationQueue.size > 0) {
635
+ while (this.invalidationQueue.size > 0 && !this.disposed) {
592
636
  const atom$1 = this.invalidationQueue.values().next().value;
593
637
  this.invalidationQueue.delete(atom$1);
594
638
  if (this.invalidationChain.has(atom$1)) {
@@ -609,6 +653,8 @@ var ScopeImpl = class {
609
653
  constructor(options) {
610
654
  this.extensions = options?.extensions ?? [];
611
655
  this.tags = options?.tags ?? [];
656
+ this.resolveExts = this.extensions.filter((e) => e.wrapResolve);
657
+ this.execExts = this.extensions.filter((e) => e.wrapExec);
612
658
  for (const p of options?.presets ?? []) this.presets.set(p.target, p.value);
613
659
  this.gcOptions = {
614
660
  enabled: options?.gc?.enabled ?? true,
@@ -701,21 +747,21 @@ var ScopeImpl = class {
701
747
  const entry = this.cache.get(atom$1);
702
748
  if (!entry) return;
703
749
  const eventListeners = entry.listeners.get(event);
704
- if (eventListeners) for (const listener of eventListeners) listener();
750
+ if (eventListeners?.size) for (const listener of [...eventListeners]) listener();
705
751
  const allListeners = entry.listeners.get("*");
706
- if (allListeners) for (const listener of allListeners) listener();
752
+ if (allListeners?.size) for (const listener of [...allListeners]) listener();
707
753
  }
708
754
  notifyAllListeners(atom$1) {
709
755
  const entry = this.cache.get(atom$1);
710
756
  if (!entry) return;
711
757
  const allListeners = entry.listeners.get("*");
712
- if (allListeners) for (const listener of allListeners) listener();
758
+ if (allListeners?.size) for (const listener of [...allListeners]) listener();
713
759
  }
714
760
  emitStateChange(state, atom$1) {
715
761
  const stateMap = this.stateListeners.get(state);
716
762
  if (stateMap) {
717
763
  const listeners = stateMap.get(atom$1);
718
- if (listeners) for (const listener of listeners) listener();
764
+ if (listeners?.size) for (const listener of [...listeners]) listener();
719
765
  }
720
766
  }
721
767
  on(event, atom$1, listener) {
@@ -741,14 +787,15 @@ var ScopeImpl = class {
741
787
  };
742
788
  }
743
789
  async resolve(atom$1) {
790
+ if (this.disposed) throw new Error("Scope is disposed");
744
791
  if (!this.initialized) await this.ready;
745
792
  const entry = this.cache.get(atom$1);
746
793
  if (entry?.state === "resolved") return entry.value;
747
794
  const pendingPromise = this.pending.get(atom$1);
748
795
  if (pendingPromise) return pendingPromise;
749
796
  if (this.resolving.has(atom$1)) throw new Error("Circular dependency detected");
750
- const presetValue = this.presets.get(atom$1);
751
- if (presetValue !== void 0) {
797
+ if (this.presets.has(atom$1)) {
798
+ const presetValue = this.presets.get(atom$1);
752
799
  if (isAtom(presetValue)) return this.resolve(presetValue);
753
800
  const newEntry = this.getOrCreateEntry(atom$1);
754
801
  newEntry.state = "resolved";
@@ -771,6 +818,10 @@ var ScopeImpl = class {
771
818
  async doResolve(atom$1) {
772
819
  const entry = this.getOrCreateEntry(atom$1);
773
820
  if (!(entry.state === "resolving")) {
821
+ for (let i = entry.cleanups.length - 1; i >= 0; i--) try {
822
+ await entry.cleanups[i]?.();
823
+ } catch {}
824
+ entry.cleanups = [];
774
825
  entry.state = "resolving";
775
826
  this.emitStateChange("resolving", atom$1);
776
827
  this.notifyListeners(atom$1, "resolving");
@@ -789,7 +840,7 @@ var ScopeImpl = class {
789
840
  };
790
841
  const factory = atom$1.factory;
791
842
  const doResolve = async () => {
792
- if (atom$1.deps && Object.keys(atom$1.deps).length > 0) return factory(ctx, resolvedDeps);
843
+ if (atom$1.deps) return factory(ctx, resolvedDeps);
793
844
  else return factory(ctx);
794
845
  };
795
846
  try {
@@ -825,70 +876,103 @@ var ScopeImpl = class {
825
876
  entry.pendingInvalidate = false;
826
877
  this.invalidationChain?.delete(atom$1);
827
878
  this.scheduleInvalidation(atom$1);
828
- }
879
+ } else if (entry.pendingSet && "value" in entry.pendingSet) {
880
+ this.invalidationChain?.delete(atom$1);
881
+ this.scheduleInvalidation(atom$1);
882
+ } else entry.pendingSet = void 0;
829
883
  throw entry.error;
830
884
  }
831
885
  }
832
886
  async applyResolveExtensions(event, doResolve) {
833
887
  let next = doResolve;
834
- for (let i = this.extensions.length - 1; i >= 0; i--) {
835
- const ext = this.extensions[i];
836
- if (ext?.wrapResolve) {
837
- const currentNext = next;
838
- next = ext.wrapResolve.bind(ext, currentNext, event);
839
- }
888
+ for (let i = this.resolveExts.length - 1; i >= 0; i--) {
889
+ const ext = this.resolveExts[i];
890
+ const currentNext = next;
891
+ next = ext.wrapResolve.bind(ext, currentNext, event);
840
892
  }
841
893
  return next();
842
894
  }
843
895
  async resolveDeps(deps, ctx, dependentAtom) {
844
896
  if (!deps) return {};
845
897
  const result = {};
846
- for (const [key, dep] of Object.entries(deps)) if (isAtom(dep)) {
847
- result[key] = await this.resolve(dep);
848
- if (dependentAtom) {
849
- const depEntry = this.getEntry(dep);
850
- if (depEntry) depEntry.dependents.add(dependentAtom);
851
- }
852
- } else if (isControllerDep(dep)) {
853
- const ctrl = new ControllerImpl(dep.atom, this);
854
- if (dep.resolve) await ctrl.resolve();
855
- result[key] = ctrl;
856
- if (dependentAtom) {
857
- const depEntry = this.getEntry(dep.atom);
858
- if (depEntry) depEntry.dependents.add(dependentAtom);
859
- }
860
- } else if (tagExecutorSymbol in dep) {
861
- const tagExecutor = dep;
862
- switch (tagExecutor.mode) {
863
- case "required": {
864
- const value = ctx ? ctx.data.seekTag(tagExecutor.tag) : tagExecutor.tag.find(this.tags);
865
- if (value !== void 0) result[key] = value;
866
- else if (tagExecutor.tag.hasDefault) result[key] = tagExecutor.tag.defaultValue;
867
- else throw new Error(`Tag "${tagExecutor.tag.label}" not found`);
868
- break;
898
+ const parallel = [];
899
+ const deferredResources = [];
900
+ for (const key in deps) {
901
+ const dep = deps[key];
902
+ if (isAtom(dep)) parallel.push(this.resolve(dep).then((value) => {
903
+ result[key] = value;
904
+ if (dependentAtom) {
905
+ const depEntry = this.getEntry(dep);
906
+ if (depEntry) depEntry.dependents.add(dependentAtom);
869
907
  }
870
- case "optional":
871
- result[key] = (ctx ? ctx.data.seekTag(tagExecutor.tag) : tagExecutor.tag.find(this.tags)) ?? tagExecutor.tag.defaultValue;
872
- break;
873
- case "all":
874
- result[key] = ctx ? this.collectFromHierarchy(ctx, tagExecutor.tag) : tagExecutor.tag.collect(this.tags);
875
- break;
876
- }
877
- } else if (isResource(dep)) {
908
+ }));
909
+ else if (isControllerDep(dep)) {
910
+ if (dep.watch) {
911
+ if (!dependentAtom) throw new Error("controller({ watch: true }) is only supported in atom dependencies");
912
+ if (!dep.resolve) throw new Error("controller({ watch: true }) requires resolve: true");
913
+ }
914
+ const ctrl = this.controller(dep.atom);
915
+ if (dep.resolve) parallel.push(ctrl.resolve().then(() => {
916
+ result[key] = ctrl;
917
+ if (dependentAtom) {
918
+ const depEntry = this.getEntry(dep.atom);
919
+ if (depEntry) depEntry.dependents.add(dependentAtom);
920
+ }
921
+ if (dep.watch) {
922
+ const eq = dep.eq ?? Object.is;
923
+ let prev = ctrl.get();
924
+ const unsub = this.on("resolved", dep.atom, () => {
925
+ const next = ctrl.get();
926
+ if (!eq(prev, next)) {
927
+ prev = next;
928
+ this.scheduleInvalidation(dependentAtom);
929
+ }
930
+ });
931
+ const depEntry = this.getEntry(dependentAtom);
932
+ if (depEntry) depEntry.cleanups.push(unsub);
933
+ else unsub();
934
+ }
935
+ }));
936
+ else {
937
+ result[key] = ctrl;
938
+ if (dependentAtom) {
939
+ const depEntry = this.getEntry(dep.atom);
940
+ if (depEntry) depEntry.dependents.add(dependentAtom);
941
+ }
942
+ }
943
+ } else if (tagExecutorSymbol in dep) {
944
+ const tagExecutor = dep;
945
+ switch (tagExecutor.mode) {
946
+ case "required": {
947
+ const value = ctx ? ctx.data.seekTag(tagExecutor.tag) : tagExecutor.tag.find(this.tags);
948
+ if (value !== void 0) result[key] = value;
949
+ else if (tagExecutor.tag.hasDefault) result[key] = tagExecutor.tag.defaultValue;
950
+ else throw new Error(`Tag "${tagExecutor.tag.label}" not found`);
951
+ break;
952
+ }
953
+ case "optional":
954
+ result[key] = (ctx ? ctx.data.seekTag(tagExecutor.tag) : tagExecutor.tag.find(this.tags)) ?? tagExecutor.tag.defaultValue;
955
+ break;
956
+ case "all":
957
+ result[key] = ctx ? this.collectFromHierarchy(ctx, tagExecutor.tag) : tagExecutor.tag.collect(this.tags);
958
+ break;
959
+ }
960
+ } else if (isResource(dep)) deferredResources.push([key, dep]);
961
+ }
962
+ if (parallel.length === 1) await parallel[0];
963
+ else if (parallel.length > 1) await Promise.all(parallel);
964
+ for (const [key, resource$1] of deferredResources) {
878
965
  if (!ctx) throw new Error("Resource deps require an ExecutionContext");
879
- const resource$1 = dep;
880
966
  const resourceKey = getResourceKey(resource$1);
881
967
  const storeCtx = ctx.parent ?? ctx;
882
968
  if (storeCtx.data.has(resourceKey)) {
883
969
  result[key] = storeCtx.data.get(resourceKey);
884
970
  continue;
885
971
  }
886
- const existingSeek = ctx.data.seek(resourceKey);
887
- if (existingSeek !== void 0 || ctx.data.has(resourceKey)) {
888
- result[key] = existingSeek;
972
+ if (ctx.data.seekHas(resourceKey)) {
973
+ result[key] = ctx.data.seek(resourceKey);
889
974
  continue;
890
975
  }
891
- if (resolvingResources.has(resourceKey)) throw new Error(`Circular resource dependency detected: ${resource$1.name ?? "anonymous"}`);
892
976
  let flights = inflightResources.get(storeCtx.data);
893
977
  if (!flights) {
894
978
  flights = /* @__PURE__ */ new Map();
@@ -899,8 +983,14 @@ var ScopeImpl = class {
899
983
  result[key] = await inflight;
900
984
  continue;
901
985
  }
986
+ let localResolvingResources = resolvingResourcesMap.get(storeCtx.data);
987
+ if (!localResolvingResources) {
988
+ localResolvingResources = /* @__PURE__ */ new Set();
989
+ resolvingResourcesMap.set(storeCtx.data, localResolvingResources);
990
+ }
991
+ if (localResolvingResources.has(resourceKey)) throw new Error(`Circular resource dependency detected: ${resource$1.name ?? "anonymous"}`);
902
992
  const resolve = async () => {
903
- resolvingResources.add(resourceKey);
993
+ localResolvingResources.add(resourceKey);
904
994
  try {
905
995
  const resourceDeps = await this.resolveDeps(resource$1.deps, ctx);
906
996
  const event = {
@@ -910,14 +1000,14 @@ var ScopeImpl = class {
910
1000
  };
911
1001
  const doResolve = async () => {
912
1002
  const factory = resource$1.factory;
913
- if (resource$1.deps && Object.keys(resource$1.deps).length > 0) return factory(storeCtx, resourceDeps);
1003
+ if (resource$1.deps) return factory(storeCtx, resourceDeps);
914
1004
  return factory(storeCtx);
915
1005
  };
916
1006
  const value = await this.applyResolveExtensions(event, doResolve);
917
1007
  storeCtx.data.set(resourceKey, value);
918
1008
  return value;
919
1009
  } finally {
920
- resolvingResources.delete(resourceKey);
1010
+ localResolvingResources.delete(resourceKey);
921
1011
  }
922
1012
  };
923
1013
  const promise = resolve();
@@ -941,6 +1031,7 @@ var ScopeImpl = class {
941
1031
  return results;
942
1032
  }
943
1033
  controller(atom$1, options) {
1034
+ if (this.disposed) throw new Error("Scope is disposed");
944
1035
  let ctrl = this.controllers.get(atom$1);
945
1036
  if (!ctrl) {
946
1037
  ctrl = new ControllerImpl(atom$1, this);
@@ -950,7 +1041,7 @@ var ScopeImpl = class {
950
1041
  return ctrl;
951
1042
  }
952
1043
  select(atom$1, selector, options) {
953
- return new SelectHandleImpl(this.controller(atom$1), selector, options?.eq ?? ((a, b) => a === b));
1044
+ return new SelectHandleImpl(this.controller(atom$1), selector, options?.eq ?? Object.is);
954
1045
  }
955
1046
  getFlowPreset(flow$1) {
956
1047
  return this.presets.get(flow$1);
@@ -994,10 +1085,27 @@ var ScopeImpl = class {
994
1085
  const previousValue = entry.value;
995
1086
  const pendingSet = entry.pendingSet;
996
1087
  entry.pendingSet = void 0;
997
- for (let i = entry.cleanups.length - 1; i >= 0; i--) {
998
- const cleanup = entry.cleanups[i];
999
- if (cleanup) await cleanup();
1088
+ if (pendingSet) {
1089
+ entry.state = "resolving";
1090
+ entry.value = previousValue;
1091
+ entry.error = void 0;
1092
+ entry.pendingInvalidate = false;
1093
+ this.pending.delete(atom$1);
1094
+ this.resolving.delete(atom$1);
1095
+ this.emitStateChange("resolving", atom$1);
1096
+ this.notifyListeners(atom$1, "resolving");
1097
+ if ("value" in pendingSet) entry.value = pendingSet.value;
1098
+ else entry.value = pendingSet.fn(previousValue);
1099
+ entry.state = "resolved";
1100
+ entry.hasValue = true;
1101
+ this.emitStateChange("resolved", atom$1);
1102
+ this.notifyListeners(atom$1, "resolved");
1103
+ this.invalidationChain?.delete(atom$1);
1104
+ return;
1000
1105
  }
1106
+ for (let i = entry.cleanups.length - 1; i >= 0; i--) try {
1107
+ await entry.cleanups[i]?.();
1108
+ } catch {}
1001
1109
  entry.cleanups = [];
1002
1110
  entry.state = "resolving";
1003
1111
  entry.value = previousValue;
@@ -1007,16 +1115,11 @@ var ScopeImpl = class {
1007
1115
  this.resolving.delete(atom$1);
1008
1116
  this.emitStateChange("resolving", atom$1);
1009
1117
  this.notifyListeners(atom$1, "resolving");
1010
- if (pendingSet) {
1011
- if ("value" in pendingSet) entry.value = pendingSet.value;
1012
- else entry.value = pendingSet.fn(previousValue);
1013
- entry.state = "resolved";
1014
- entry.hasValue = true;
1015
- this.emitStateChange("resolved", atom$1);
1016
- this.notifyListeners(atom$1, "resolved");
1017
- return;
1118
+ try {
1119
+ await this.resolve(atom$1);
1120
+ } catch (e) {
1121
+ if (!entry.pendingSet && !entry.pendingInvalidate) throw e;
1018
1122
  }
1019
- await this.resolve(atom$1);
1020
1123
  }
1021
1124
  async release(atom$1) {
1022
1125
  const entry = this.cache.get(atom$1);
@@ -1025,14 +1128,33 @@ var ScopeImpl = class {
1025
1128
  clearTimeout(entry.gcScheduled);
1026
1129
  entry.gcScheduled = null;
1027
1130
  }
1028
- for (let i = entry.cleanups.length - 1; i >= 0; i--) {
1029
- const cleanup = entry.cleanups[i];
1030
- if (cleanup) await cleanup();
1131
+ for (let i = entry.cleanups.length - 1; i >= 0; i--) try {
1132
+ await entry.cleanups[i]?.();
1133
+ } catch {}
1134
+ if (atom$1.deps) for (const dep of Object.values(atom$1.deps)) {
1135
+ const depAtom = isAtom(dep) ? dep : isControllerDep(dep) ? dep.atom : null;
1136
+ if (!depAtom) continue;
1137
+ const depEntry = this.cache.get(depAtom);
1138
+ if (depEntry) {
1139
+ depEntry.dependents.delete(atom$1);
1140
+ this.maybeScheduleGC(depAtom);
1141
+ }
1031
1142
  }
1032
1143
  this.cache.delete(atom$1);
1033
1144
  this.controllers.delete(atom$1);
1145
+ for (const [state, stateMap] of this.stateListeners) {
1146
+ stateMap.delete(atom$1);
1147
+ if (stateMap.size === 0) this.stateListeners.delete(state);
1148
+ }
1034
1149
  }
1035
1150
  async dispose() {
1151
+ if (this.chainPromise) try {
1152
+ await this.chainPromise;
1153
+ } catch {}
1154
+ this.disposed = true;
1155
+ this.invalidationQueue.clear();
1156
+ this.invalidationChain = null;
1157
+ this.chainPromise = null;
1036
1158
  for (const ext of this.extensions) if (ext.dispose) await ext.dispose(this);
1037
1159
  for (const entry of this.cache.values()) if (entry.gcScheduled) {
1038
1160
  clearTimeout(entry.gcScheduled);
@@ -1043,8 +1165,14 @@ var ScopeImpl = class {
1043
1165
  }
1044
1166
  async flush() {
1045
1167
  if (this.chainPromise) await this.chainPromise;
1168
+ if (this.chainError !== null) {
1169
+ const error = this.chainError;
1170
+ this.chainError = null;
1171
+ throw error;
1172
+ }
1046
1173
  }
1047
1174
  createContext(options) {
1175
+ if (this.disposed) throw new Error("Scope is disposed");
1048
1176
  const ctx = new ExecutionContextImpl(this, options);
1049
1177
  for (const tagged of options?.tags ?? []) ctx.data.set(tagged.key, tagged.value);
1050
1178
  for (const tagged of this.tags) if (!ctx.data.has(tagged.key)) ctx.data.set(tagged.key, tagged.value);
@@ -1138,7 +1266,7 @@ var ExecutionContextImpl = class ExecutionContextImpl {
1138
1266
  const resolvedDeps = await this.scope.resolveDeps(flow$1.deps, this);
1139
1267
  const factory = flow$1.factory;
1140
1268
  const doExec = async () => {
1141
- if (flow$1.deps && Object.keys(flow$1.deps).length > 0) return factory(this, resolvedDeps);
1269
+ if (flow$1.deps) return factory(this, resolvedDeps);
1142
1270
  else return factory(this);
1143
1271
  };
1144
1272
  return this.applyExecExtensions(flow$1, doExec);
@@ -1154,12 +1282,10 @@ var ExecutionContextImpl = class ExecutionContextImpl {
1154
1282
  }
1155
1283
  async applyExecExtensions(target, doExec) {
1156
1284
  let next = doExec;
1157
- for (let i = this.scope.extensions.length - 1; i >= 0; i--) {
1158
- const ext = this.scope.extensions[i];
1159
- if (ext?.wrapExec) {
1160
- const currentNext = next;
1161
- next = ext.wrapExec.bind(ext, currentNext, target, this);
1162
- }
1285
+ for (let i = this.scope.execExts.length - 1; i >= 0; i--) {
1286
+ const ext = this.scope.execExts[i];
1287
+ const currentNext = next;
1288
+ next = ext.wrapExec.bind(ext, currentNext, target, this);
1163
1289
  }
1164
1290
  return next();
1165
1291
  }
@@ -1169,10 +1295,9 @@ var ExecutionContextImpl = class ExecutionContextImpl {
1169
1295
  async close(result = { ok: true }) {
1170
1296
  if (this.closed) return;
1171
1297
  this.closed = true;
1172
- for (let i = this.cleanups.length - 1; i >= 0; i--) {
1173
- const cleanup = this.cleanups[i];
1174
- if (cleanup) await cleanup(result);
1175
- }
1298
+ for (let i = this.cleanups.length - 1; i >= 0; i--) try {
1299
+ await this.cleanups[i]?.(result);
1300
+ } catch {}
1176
1301
  }
1177
1302
  };
1178
1303
  /**
package/dist/index.d.cts CHANGED
@@ -89,6 +89,10 @@ declare namespace Lite {
89
89
  * Returns first match or undefined (ignores tag defaults).
90
90
  */
91
91
  seekTag<T>(tag: Tag<T, boolean>): T | undefined;
92
+ /**
93
+ * Check if key exists locally or in parent chain.
94
+ */
95
+ seekHas(key: string | symbol): boolean;
92
96
  /** Get value by tag, returns undefined if not stored */
93
97
  getTag<T>(tag: Tag<T, boolean>): T | undefined;
94
98
  /** Set value by tag */
@@ -198,6 +202,7 @@ declare namespace Lite {
198
202
  interface SelectHandle<S> {
199
203
  get(): S;
200
204
  subscribe(listener: () => void): () => void;
205
+ dispose(): void;
201
206
  }
202
207
  interface Tag<T, HasDefault extends boolean = false> {
203
208
  readonly [tagSymbol]: true;
@@ -230,10 +235,29 @@ declare namespace Lite {
230
235
  readonly [controllerDepSymbol]: true;
231
236
  readonly atom: Atom<T>;
232
237
  readonly resolve?: boolean;
238
+ readonly watch?: boolean;
239
+ readonly eq?: (a: any, b: any) => boolean;
233
240
  }
234
241
  interface ControllerOptions {
235
242
  resolve?: boolean;
236
243
  }
244
+ type ControllerDepOptions<T> = {
245
+ resolve: true;
246
+ watch: true;
247
+ eq: (a: T, b: T) => boolean;
248
+ } | {
249
+ resolve: true;
250
+ watch: true;
251
+ eq?: never;
252
+ } | {
253
+ resolve: true;
254
+ watch?: never;
255
+ eq?: never;
256
+ } | {
257
+ resolve?: never;
258
+ watch?: never;
259
+ eq?: never;
260
+ };
237
261
  interface Typed<T> {
238
262
  readonly [typedSymbol]: true;
239
263
  }
@@ -572,24 +596,37 @@ declare function isAtom(value: unknown): value is Lite.Atom<unknown>;
572
596
  * The Controller provides full lifecycle control: get, resolve, release, invalidate, and subscribe.
573
597
  *
574
598
  * @param atom - The Atom to wrap
575
- * @param options - Optional configuration. Use { resolve: true } to auto-resolve before factory runs.
599
+ * @param options - Optional configuration:
600
+ * - `resolve: true` — auto-resolves the dep before the parent factory runs; `config.get()` is safe.
601
+ * - `watch: true` — atom deps only; requires `resolve: true`; automatically re-runs the parent factory
602
+ * when the dep resolves to a new value (value-equality gated via `Object.is` by default). Replaces
603
+ * manual `ctx.cleanup(ctx.scope.on('resolved', dep, () => ctx.invalidate()))` wiring. Watch
604
+ * listeners are auto-cleaned on re-resolve, release, and dispose.
605
+ * - `eq` — custom equality function `(a: T, b: T) => boolean`; only used with `watch: true`.
576
606
  * @returns A ControllerDep that resolves to a Controller for the Atom
577
607
  *
578
608
  * @example
579
609
  * ```typescript
580
- * const configAtom = atom({ factory: () => fetchConfig() })
610
+ * // resolve only
581
611
  * const serverAtom = atom({
582
612
  * deps: { config: controller(configAtom, { resolve: true }) },
583
- * factory: (ctx, { config }) => {
584
- * // config.get() is safe - already resolved
585
- * const unsub = config.on('resolved', () => ctx.invalidate())
586
- * ctx.cleanup(unsub)
587
- * return createServer(config.get().port)
588
- * }
613
+ * factory: (_, { config }) => createServer(config.get().port),
614
+ * })
615
+ *
616
+ * // watch: re-runs parent when dep value changes
617
+ * const profileAtom = atom({
618
+ * deps: { token: controller(tokenAtom, { resolve: true, watch: true }) },
619
+ * factory: (_, { token }) => ({ id: `user-${token.get().jwt}` }),
620
+ * })
621
+ *
622
+ * // watch with custom equality
623
+ * const derivedAtom = atom({
624
+ * deps: { src: controller(srcAtom, { resolve: true, watch: true, eq: (a, b) => a.id === b.id }) },
625
+ * factory: (_, { src }) => src.get().name,
589
626
  * })
590
627
  * ```
591
628
  */
592
- declare function controller<T>(atom: Lite.Atom<T>, options?: Lite.ControllerOptions): Lite.ControllerDep<T>;
629
+ declare function controller<T>(atom: Lite.Atom<T>, options?: Lite.ControllerDepOptions<T>): Lite.ControllerDep<T>;
593
630
  /**
594
631
  * Type guard to check if a value is a ControllerDep wrapper.
595
632
  *