valdres 1.0.0-beta.14 → 1.0.0-beta.15

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.js CHANGED
@@ -112,15 +112,15 @@ var equal = (a, b, updatedAtomsSet) => {
112
112
  }
113
113
  };
114
114
 
115
+ // src/utils/isSelector.ts
116
+ var isSelector = (state) => state && Object.hasOwn(state, "get");
117
+
115
118
  // src/utils/isAtom.ts
116
119
  var isAtom = (state) => state && Object.hasOwn(state, "defaultValue");
117
120
 
118
121
  // src/utils/isGlobalAtom.ts
119
122
  var isGlobalAtom = (state) => state && Object.hasOwn(state, "stores");
120
123
 
121
- // src/utils/isSelector.ts
122
- var isSelector = (state) => state && Object.hasOwn(state, "get");
123
-
124
124
  // src/utils/isAtomFamily.ts
125
125
  var isAtomFamily = (state) => state && Object.hasOwn(state, "__valdresAtomFamilyMap");
126
126
 
@@ -346,6 +346,14 @@ ${generateSelectorTrace(this.selectors)}`;
346
346
  }
347
347
  }
348
348
 
349
+ // src/lib/noteDependencyGraphChanged.ts
350
+ var noteDependencyGraphChanged = (selector, data) => {
351
+ if (!data.dependencyOrder.has(selector)) {
352
+ data.dependencyOrder.set(selector, data.nextDependencyOrder++);
353
+ }
354
+ data.dependencyGraphVersion++;
355
+ };
356
+
349
357
  // src/lib/asyncDependencyTracking.ts
350
358
  var pendingAsyncDeps = new WeakMap;
351
359
 
@@ -375,6 +383,7 @@ var lateGet = (state, selector, data) => {
375
383
  }
376
384
  const isNewDep = !deps.has(state);
377
385
  if (isNewDep) {
386
+ noteDependencyGraphChanged(selector, data);
378
387
  deps.add(state);
379
388
  const dependents = getOrInitDependentsSet(state, data);
380
389
  dependents.add(selector);
@@ -812,6 +821,7 @@ var evaluateSelector = (selector, data, initializedAtomsSet, circularDependencyS
812
821
  depsChanged = true;
813
822
  }
814
823
  if (depsChanged || !currentDependencies) {
824
+ noteDependencyGraphChanged(selector, data);
815
825
  if (data.livenessPassActive && currentDependencies) {
816
826
  (data.livenessSeeds ??= new Set).add(selector);
817
827
  if (!depsChangeOut)
@@ -901,8 +911,13 @@ var handleSelectorResult = (value, selector, data) => {
901
911
  const currentDeps = data.stateDependencies.get(selector);
902
912
  if (currentDeps) {
903
913
  const selectorIsLive = isLive(selector, data);
914
+ let graphChangeNoted = false;
904
915
  for (const dep of currentDeps) {
905
916
  if (!evalDeps.has(dep)) {
917
+ if (!graphChangeNoted) {
918
+ noteDependencyGraphChanged(selector, data);
919
+ graphChangeNoted = true;
920
+ }
906
921
  currentDeps.delete(dep);
907
922
  const dependents2 = data.stateDependents.get(dep);
908
923
  if (dependents2)
@@ -1773,8 +1788,12 @@ var setAtom = (atom, newValue, data, skipOnSet = false) => {
1773
1788
  }
1774
1789
  let syncValue = validateSchema(atom, newValue, data);
1775
1790
  const areEqual = isPromiseLike(currentValue) ? currentValue === syncValue : atom.equal(currentValue, syncValue);
1776
- if (areEqual)
1791
+ if (areEqual) {
1792
+ if (data.parent && !data.values.has(atom)) {
1793
+ return setValueInData(atom, syncValue, data);
1794
+ }
1777
1795
  return syncValue;
1796
+ }
1778
1797
  syncValue = setValueInData(atom, syncValue, data);
1779
1798
  if (atom.onSet && !skipOnSet)
1780
1799
  atom.onSet(syncValue, data);
@@ -2040,6 +2059,10 @@ Object.defineProperties(lazyProto, {
2040
2059
  subscriptionsRequireEqualCheck: makeLazyGetter("subscriptionsRequireEqualCheck"),
2041
2060
  stateDependents: makeLazyGetter("stateDependents"),
2042
2061
  stateDependencies: makeLazyGetter("stateDependencies"),
2062
+ dependencyOrder: makeLazyGetter("dependencyOrder"),
2063
+ cycleRiskInClosure: makeLazyGetter("cycleRiskInClosure"),
2064
+ acyclicDependencyVersion: makeLazyGetter("acyclicDependencyVersion"),
2065
+ orphanCleanupVersion: makeLazyGetter("orphanCleanupVersion"),
2043
2066
  mounts: makeLazyGetter("mounts"),
2044
2067
  liveDependentCount: makeLazyGetter("liveDependentCount"),
2045
2068
  mountInClosure: makeLazyGetter("mountInClosure"),
@@ -2055,6 +2078,10 @@ function createStoreData(id, parent, options) {
2055
2078
  if (enumerable)
2056
2079
  data.enumerable = true;
2057
2080
  data.values = enumerable ? new Map : new WeakMap;
2081
+ data.nextDependencyOrder = 0;
2082
+ data.dependencyGraphVersion = 0;
2083
+ data.pendingOrphanCleanup = undefined;
2084
+ data.orphanCleanupScheduled = false;
2058
2085
  data.scopes = new Map;
2059
2086
  data.scopeValueIndex = new WeakMap;
2060
2087
  data.pendingDefaults = new WeakMap;
@@ -2085,6 +2112,56 @@ var deleteFamilyAtom = (atom, data) => {
2085
2112
  propagateDeletedAtoms([atom], data, undefined, undefined, undefined, undefined, "delete");
2086
2113
  };
2087
2114
 
2115
+ // src/lib/cleanupOrphanedDeps.ts
2116
+ var cleanupOrphanedDeps = (state, data) => {
2117
+ if (isLive(state, data))
2118
+ return;
2119
+ const graphVersion = data.dependencyGraphVersion;
2120
+ const cleanedAtVersion = data.orphanCleanupVersion;
2121
+ if (cleanedAtVersion.get(state) === graphVersion)
2122
+ return;
2123
+ const stack = [state];
2124
+ while (stack.length > 0) {
2125
+ const current = stack.pop();
2126
+ if (cleanedAtVersion.get(current) === graphVersion)
2127
+ continue;
2128
+ if (isLive(current, data))
2129
+ continue;
2130
+ cleanedAtVersion.set(current, graphVersion);
2131
+ const dependents = data.stateDependents.get(current);
2132
+ const deps = data.stateDependencies.get(current);
2133
+ if (deps) {
2134
+ for (const dep of deps) {
2135
+ data.stateDependents.get(dep)?.delete(current);
2136
+ }
2137
+ data.stateDependencies.delete(current);
2138
+ data.values.delete(current);
2139
+ data.abortControllers.delete(current);
2140
+ if (dependents) {
2141
+ for (const dependent of dependents)
2142
+ stack.push(dependent);
2143
+ }
2144
+ for (const dep of deps)
2145
+ stack.push(dep);
2146
+ continue;
2147
+ }
2148
+ if (dependents) {
2149
+ for (const dependent of dependents)
2150
+ stack.push(dependent);
2151
+ }
2152
+ }
2153
+ };
2154
+
2155
+ // src/lib/flushPendingOrphanCleanup.ts
2156
+ var flushPendingOrphanCleanup = (data) => {
2157
+ const pending = data.pendingOrphanCleanup;
2158
+ if (!pending)
2159
+ return;
2160
+ data.pendingOrphanCleanup = undefined;
2161
+ for (const state of pending)
2162
+ cleanupOrphanedDeps(state, data);
2163
+ };
2164
+
2088
2165
  // src/lib/onStoreChange.ts
2089
2166
  var onStoreChange = (callback, data, options) => {
2090
2167
  const atoms = options?.atoms ?? true;
@@ -2186,6 +2263,26 @@ var deleteMaxAgeCleanup = (data, state) => {
2186
2263
  maxAgeCleanups.get(data)?.delete(state);
2187
2264
  };
2188
2265
 
2266
+ // src/lib/queueOrphanCleanup.ts
2267
+ var queueOrphanCleanup = (state, data) => {
2268
+ if (!data.stateDependencies.has(state) && !data.stateDependents.get(state)?.size) {
2269
+ return;
2270
+ }
2271
+ let pending = data.pendingOrphanCleanup;
2272
+ if (!pending) {
2273
+ pending = new Set;
2274
+ data.pendingOrphanCleanup = pending;
2275
+ }
2276
+ pending.add(state);
2277
+ if (data.orphanCleanupScheduled)
2278
+ return;
2279
+ data.orphanCleanupScheduled = true;
2280
+ queueMicrotask(() => {
2281
+ data.orphanCleanupScheduled = false;
2282
+ flushPendingOrphanCleanup(data);
2283
+ });
2284
+ };
2285
+
2189
2286
  // src/lib/unsubscribe.ts
2190
2287
  var unsubscribe = (state, subscription, data) => {
2191
2288
  const subscribers = data.subscriptions.get(state);
@@ -2211,49 +2308,12 @@ var unsubscribe = (state, subscription, data) => {
2211
2308
  }
2212
2309
  data.subscriptions.delete(state);
2213
2310
  onLastDirectSubscriber(state, data);
2214
- if (isSelector(state) && regionHasCycle(new Set([state]), data)) {
2311
+ if (isSelector(state) && data.cycleRiskInClosure.has(state) && regionHasCycle(state, data)) {
2215
2312
  reconcileLivenessAfterChurn(new Set([state]), data);
2216
2313
  }
2217
- unmountOrphanedDeps(state, data);
2218
- cleanupOrphanedDeps(state, data);
2219
- }
2220
- }
2221
- };
2222
- var cleanupOrphanedDeps = (state, data) => {
2223
- const visited = new Set;
2224
- const stack = [state];
2225
- while (stack.length > 0) {
2226
- const current = stack.pop();
2227
- if (visited.has(current))
2228
- continue;
2229
- visited.add(current);
2230
- if (isLive(current, data))
2231
- continue;
2232
- const dependents = data.stateDependents.get(current);
2233
- const deps = data.stateDependencies.get(current);
2234
- if (deps) {
2235
- for (const dep of deps) {
2236
- const depDependents = data.stateDependents.get(dep);
2237
- if (depDependents) {
2238
- depDependents.delete(current);
2239
- }
2240
- }
2241
- data.stateDependencies.delete(current);
2242
- data.values.delete(current);
2243
- data.abortControllers.delete(current);
2244
- if (dependents) {
2245
- for (const dep of dependents) {
2246
- stack.push(dep);
2247
- }
2248
- }
2249
- for (const dep of deps) {
2250
- stack.push(dep);
2251
- }
2252
- continue;
2253
- }
2254
- if (dependents) {
2255
- for (const dep of dependents) {
2256
- stack.push(dep);
2314
+ if (!isLive(state, data)) {
2315
+ unmountOrphanedDeps(state, data);
2316
+ queueOrphanCleanup(state, data);
2257
2317
  }
2258
2318
  }
2259
2319
  }
@@ -3032,6 +3092,8 @@ function storeFromStoreData(data, detach) {
3032
3092
  return _pendingTxn;
3033
3093
  };
3034
3094
  const getDefault = (state) => {
3095
+ if (data.pendingOrphanCleanup)
3096
+ flushPendingOrphanCleanup(data);
3035
3097
  if (data.values.has(state)) {
3036
3098
  if (!isCachedValueStale(state, data)) {
3037
3099
  return data.values.get(state);
@@ -3064,12 +3126,16 @@ function storeFromStoreData(data, detach) {
3064
3126
  };
3065
3127
  const getBatched = (state) => {
3066
3128
  if (_pendingTxn) {
3129
+ if (data.pendingOrphanCleanup)
3130
+ flushPendingOrphanCleanup(data);
3067
3131
  return _pendingTxn.get(state);
3068
3132
  }
3069
3133
  return getDefault(state);
3070
3134
  };
3071
3135
  const get = data.batchUpdates ? getBatched : getDefault;
3072
3136
  const setDefault = (state, value) => {
3137
+ if (data.pendingOrphanCleanup)
3138
+ flushPendingOrphanCleanup(data);
3073
3139
  if (isAtom(state))
3074
3140
  return setAtom(state, value, data);
3075
3141
  if (isSelector(state))
@@ -3077,6 +3143,8 @@ function storeFromStoreData(data, detach) {
3077
3143
  throw new Error(InvalidStateSetError);
3078
3144
  };
3079
3145
  const setBatched = (state, value) => {
3146
+ if (data.pendingOrphanCleanup)
3147
+ flushPendingOrphanCleanup(data);
3080
3148
  if (isAtom(state)) {
3081
3149
  return ensurePendingTxn().set(state, value);
3082
3150
  }
@@ -3086,29 +3154,45 @@ function storeFromStoreData(data, detach) {
3086
3154
  };
3087
3155
  const set = data.batchUpdates ? setBatched : setDefault;
3088
3156
  const reset = (atom) => {
3157
+ if (data.pendingOrphanCleanup)
3158
+ flushPendingOrphanCleanup(data);
3089
3159
  if (data.batchUpdates)
3090
3160
  flushPendingTxn();
3091
3161
  return resetAtom(atom, data);
3092
3162
  };
3093
3163
  const del = (atom) => {
3164
+ if (data.pendingOrphanCleanup)
3165
+ flushPendingOrphanCleanup(data);
3094
3166
  if (data.batchUpdates)
3095
3167
  flushPendingTxn();
3096
3168
  return deleteFamilyAtom(atom, data);
3097
3169
  };
3098
3170
  const unset = (atom) => {
3171
+ if (data.pendingOrphanCleanup)
3172
+ flushPendingOrphanCleanup(data);
3099
3173
  if (data.batchUpdates)
3100
3174
  flushPendingTxn();
3101
3175
  return unsetValue(atom, data);
3102
3176
  };
3103
- const sub = (state, callback, deepEqualCheckBeforeCallback = true) => subscribe(state, callback, deepEqualCheckBeforeCallback, data);
3177
+ const sub = (state, callback, deepEqualCheckBeforeCallback = true) => {
3178
+ if (data.pendingOrphanCleanup)
3179
+ flushPendingOrphanCleanup(data);
3180
+ return subscribe(state, callback, deepEqualCheckBeforeCallback, data);
3181
+ };
3104
3182
  const txn = (callback, name) => {
3183
+ if (data.pendingOrphanCleanup)
3184
+ flushPendingOrphanCleanup(data);
3105
3185
  if (data.batchUpdates)
3106
3186
  flushPendingTxn();
3107
3187
  return transaction(callback, data, name);
3108
3188
  };
3109
3189
  const onChange = (callback, options) => onStoreChange(callback, data, options);
3110
3190
  const storeOnCommitEnd = (callback) => onCommitEnd(callback, data);
3111
- const storeSnapshot = () => snapshot(data);
3191
+ const storeSnapshot = () => {
3192
+ if (data.pendingOrphanCleanup)
3193
+ flushPendingOrphanCleanup(data);
3194
+ return snapshot(data);
3195
+ };
3112
3196
  const scope = (scopeId, callback) => {
3113
3197
  if (callback) {
3114
3198
  if (!data.scopes.has(scopeId)) {
@@ -3212,7 +3296,30 @@ var propagateMountMarkerUp = (state, data) => {
3212
3296
  }
3213
3297
  }
3214
3298
  };
3299
+ var propagateCycleRiskUp = (state, data) => {
3300
+ if (data.cycleRiskInClosure.has(state))
3301
+ return;
3302
+ data.cycleRiskInClosure.set(state, true);
3303
+ const stack = [state];
3304
+ while (stack.length > 0) {
3305
+ const current = stack.pop();
3306
+ const parents = data.stateDependents.get(current);
3307
+ if (!parents)
3308
+ continue;
3309
+ for (const parent of parents) {
3310
+ if (data.cycleRiskInClosure.has(parent))
3311
+ continue;
3312
+ data.cycleRiskInClosure.set(parent, true);
3313
+ stack.push(parent);
3314
+ }
3315
+ }
3316
+ };
3215
3317
  var noteDependencyAdded = (selector, dep, data) => {
3318
+ const selectorOrder = data.dependencyOrder.get(selector);
3319
+ const depOrder = data.dependencyOrder.get(dep);
3320
+ if (data.cycleRiskInClosure.has(dep) || selectorOrder === undefined || isSelector(dep) && (depOrder === undefined || depOrder >= selectorOrder)) {
3321
+ propagateCycleRiskUp(selector, data);
3322
+ }
3216
3323
  if (!hasMountInClosure(dep, data))
3217
3324
  return;
3218
3325
  if (data.mountInClosure.has(selector))
@@ -3295,36 +3402,53 @@ var onLiveDependencyRemoved = (dep, data) => {
3295
3402
  propagateNotLive(dep, data);
3296
3403
  }
3297
3404
  };
3298
- var regionHasCycle = (seeds, data) => {
3299
- const done = new Set;
3405
+ var seedClosureHasCycle = (seed, data, graphVersion, acyclicAtVersion) => {
3406
+ if (acyclicAtVersion.get(seed) === graphVersion)
3407
+ return false;
3300
3408
  const onPath = new Set;
3301
- for (const seed of seeds) {
3302
- if (done.has(seed))
3409
+ const stack = [
3410
+ { node: seed, it: (data.stateDependencies.get(seed) ?? EMPTY).values() }
3411
+ ];
3412
+ onPath.add(seed);
3413
+ while (stack.length > 0) {
3414
+ const frame = stack[stack.length - 1];
3415
+ const next = frame.it.next();
3416
+ if (next.done) {
3417
+ onPath.delete(frame.node);
3418
+ acyclicAtVersion.set(frame.node, graphVersion);
3419
+ stack.pop();
3303
3420
  continue;
3304
- const stack = [
3305
- { node: seed, it: (data.stateDependencies.get(seed) ?? EMPTY).values() }
3306
- ];
3307
- onPath.add(seed);
3308
- while (stack.length > 0) {
3309
- const frame = stack[stack.length - 1];
3310
- const next = frame.it.next();
3311
- if (next.done) {
3312
- onPath.delete(frame.node);
3313
- done.add(frame.node);
3314
- stack.pop();
3315
- continue;
3316
- }
3317
- const dep = next.value;
3318
- if (onPath.has(dep))
3421
+ }
3422
+ const dep = next.value;
3423
+ if (onPath.has(dep))
3424
+ return true;
3425
+ if (acyclicAtVersion.get(dep) === graphVersion)
3426
+ continue;
3427
+ onPath.add(dep);
3428
+ stack.push({
3429
+ node: dep,
3430
+ it: (data.stateDependencies.get(dep) ?? EMPTY).values()
3431
+ });
3432
+ }
3433
+ return false;
3434
+ };
3435
+ var regionHasCycle = (seeds, data) => {
3436
+ const graphVersion = data.dependencyGraphVersion;
3437
+ const acyclicAtVersion = data.acyclicDependencyVersion;
3438
+ if (seeds instanceof Set) {
3439
+ for (const seed of seeds) {
3440
+ if (seedClosureHasCycle(seed, data, graphVersion, acyclicAtVersion)) {
3319
3441
  return true;
3320
- if (done.has(dep))
3321
- continue;
3322
- onPath.add(dep);
3323
- stack.push({
3324
- node: dep,
3325
- it: (data.stateDependencies.get(dep) ?? EMPTY).values()
3326
- });
3442
+ }
3327
3443
  }
3444
+ return false;
3445
+ }
3446
+ return seedClosureHasCycle(seeds, data, graphVersion, acyclicAtVersion);
3447
+ };
3448
+ var seededRegionMayHaveCycle = (seeds, data) => {
3449
+ for (const seed of seeds) {
3450
+ if (data.cycleRiskInClosure.has(seed))
3451
+ return true;
3328
3452
  }
3329
3453
  return false;
3330
3454
  };
@@ -3344,7 +3468,7 @@ var endLivenessPass = (data) => {
3344
3468
  data.livenessSeeds = undefined;
3345
3469
  data.livenessLazyArmed = false;
3346
3470
  data.livenessRemovalArmed = false;
3347
- if (seeds && seeds.size > 0 && (lazyArmed || removalArmed && regionHasCycle(seeds, data))) {
3471
+ if (seeds && seeds.size > 0 && (lazyArmed || removalArmed && seededRegionMayHaveCycle(seeds, data) && regionHasCycle(seeds, data))) {
3348
3472
  return seeds;
3349
3473
  }
3350
3474
  return null;
@@ -3687,6 +3811,7 @@ function atom(defaultValue, options) {
3687
3811
  return created;
3688
3812
  }
3689
3813
  // src/lib/stableStringify.ts
3814
+ var compareStrings = (a, b) => a < b ? -1 : a > b ? 1 : 0;
3690
3815
  var stableStringifyRecurse = (x, key) => {
3691
3816
  if (typeof x === "string" && !x.includes('"') && !x.includes("\\")) {
3692
3817
  return `"${x}"`;
@@ -3720,14 +3845,18 @@ var stableStringifyRecurse = (x, key) => {
3720
3845
  return stableStringifyRecurse(x.toJSON(key), key);
3721
3846
  }
3722
3847
  if (x instanceof Map) {
3723
- const obj = {};
3724
- for (const [k, v] of x) {
3725
- obj[typeof k === "string" ? k : stringify(k, opt)] = v;
3726
- }
3727
- return stableStringifyRecurse(obj, key);
3848
+ const entries = Array.from(x, ([k, v]) => [
3849
+ stableStringifyRecurse(k),
3850
+ stableStringifyRecurse(v)
3851
+ ]).sort(([ak, av], [bk, bv]) => {
3852
+ const keyOrder = compareStrings(ak, bk);
3853
+ return keyOrder || compareStrings(av, bv);
3854
+ });
3855
+ return `__MAP(${stableStringifyRecurse(entries, key)})__`;
3728
3856
  }
3729
3857
  if (x instanceof Set) {
3730
- return stableStringifyRecurse(Array.from(x).sort((a, b) => stableStringifyRecurse(a).localeCompare(stableStringifyRecurse(b))), key);
3858
+ const values = Array.from(x, (v) => stableStringifyRecurse(v)).sort();
3859
+ return `__SET(${stableStringifyRecurse(values, key)})__`;
3731
3860
  }
3732
3861
  if (Symbol !== undefined && x[Symbol.iterator] != null && typeof x[Symbol.iterator] === "function") {
3733
3862
  return stableStringifyRecurse(Array.from(x), key);
@@ -4091,9 +4220,9 @@ var isFamilySelector = (state) => isFamilyState(state) && isSelector(state);
4091
4220
  // src/index.ts
4092
4221
  var slot = valdresGlobal();
4093
4222
  if (slot.version) {
4094
- throw new Error(`Error! An instance of valdres is already loaded. Loaded: ${slot.version}. Attempted to load: ${"1.0.0-beta.14"}`);
4223
+ throw new Error(`Error! An instance of valdres is already loaded. Loaded: ${slot.version}. Attempted to load: ${"1.0.0-beta.15"}`);
4095
4224
  } else {
4096
- slot.version = "1.0.0-beta.14";
4225
+ slot.version = "1.0.0-beta.15";
4097
4226
  }
4098
4227
  export {
4099
4228
  store,
@@ -0,0 +1,9 @@
1
+ import type { State } from "../types/State";
2
+ import type { StoreData } from "../types/StoreData";
3
+ /**
4
+ * Remove non-live states from the dependency graph, clear selector caches, and
5
+ * recursively clean orphaned dependencies/dependents. Visit marks persist for
6
+ * one topology generation so every state is processed once across a queued
7
+ * sibling-unsubscribe burst.
8
+ */
9
+ export declare const cleanupOrphanedDeps: (state: State, data: StoreData) => void;
@@ -0,0 +1,7 @@
1
+ import type { StoreData } from "../types/StoreData";
2
+ /**
3
+ * Drain this store's queued orphan roots synchronously. Public store operations
4
+ * call this first so microtask batching does not change their observable cache
5
+ * semantics.
6
+ */
7
+ export declare const flushPendingOrphanCleanup: (data: StoreData) => void;
@@ -41,27 +41,7 @@ export declare const onLiveDependencyAdded: (dep: State, data: StoreData) => voi
41
41
  * if the contribution was the last one keeping dep alive.
42
42
  */
43
43
  export declare const onLiveDependencyRemoved: (dep: State, data: StoreData) => void;
44
- /**
45
- * Does the DOWNWARD (dependency) closure of `seeds` contain a directed cycle?
46
- *
47
- * This is the exact gate for the removal-armed liveness reconcile. Both bugs the
48
- * reconcile fixes provably require a cycle inside the affected region:
49
- * - FREEZE: a still-live selector S is stranded only if `propagateNotLive`,
50
- * walking DOWN from a removed dep D, reaches back to S — i.e. D depends
51
- * (transitively) on S while S reads D, a cycle through D and S.
52
- * - LEAK: a reference count fails to drain only a cycle; a DAG always drains
53
- * via the `prev === 1` guard.
54
- * So on an acyclic region the incremental onLiveDependencyRemoved + propagateNotLive
55
- * already equals ground truth and the reconcile is a no-op — skip it.
56
- *
57
- * Iterative DFS over `data.stateDependencies` (down-edges only) restricted to the
58
- * seeds' closure; an `onPath` (gray) set detects a back-edge, a `done` (black) set
59
- * memoizes fully-explored acyclic subgraphs. Only selectors are keyed in
60
- * `stateDependencies`; atoms and atom-family members are graph sinks (no
61
- * out-edges), so a region of pure atom deps returns false in O(seeds).
62
- * (selectorFamily members ARE selectors and do have out-edges.)
63
- */
64
- export declare const regionHasCycle: (seeds: Set<State>, data: StoreData) => boolean;
44
+ export declare const regionHasCycle: (seeds: Set<State> | State, data: StoreData) => boolean;
65
45
  /**
66
46
  * Begin a liveness-reconcile pass. Returns true iff THIS call owns the pass (the
67
47
  * outermost one) — a nested pass returns false and must not release or reconcile.
@@ -0,0 +1,7 @@
1
+ import type { State } from "../types/State";
2
+ import type { StoreData } from "../types/StoreData";
3
+ /**
4
+ * Invalidate topology-sensitive teardown caches after graph construction or
5
+ * churn, and assign the selector's stable per-store materialization order.
6
+ */
7
+ export declare const noteDependencyGraphChanged: (selector: State, data: StoreData) => void;
@@ -0,0 +1,9 @@
1
+ import type { State } from "../types/State";
2
+ import type { StoreData } from "../types/StoreData";
3
+ /**
4
+ * Batch orphan graph/value cleanup across a synchronous unsubscribe burst.
5
+ * This one-microtask delay is intentional: lifecycle cleanup already ran
6
+ * synchronously, and every public store operation drains this queue before it
7
+ * can observe or mutate selector caches.
8
+ */
9
+ export declare const queueOrphanCleanup: (state: State, data: StoreData) => void;
@@ -16,6 +16,34 @@ export type StoreData = {
16
16
  subscriptionsRequireEqualCheck: WeakMap<WeakKey, boolean>;
17
17
  stateDependents: WeakMap<WeakKey, any>;
18
18
  stateDependencies: WeakMap<WeakKey, any>;
19
+ /** Stable per-store order assigned when a selector first materializes its
20
+ * dependency set. An edge to an equal/newer selector violates this order,
21
+ * making a directed cycle possible in that closure. */
22
+ dependencyOrder: WeakMap<WeakKey, number>;
23
+ nextDependencyOrder: number;
24
+ /** Monotonic, conservative marker: present when a state's downward closure
25
+ * may contain an edge that violates `dependencyOrder`. Every real cycle has
26
+ * at least one such edge, so absence proves the closure acyclic in O(1).
27
+ * Stale positives after edge removal only cost a fallback DFS. */
28
+ cycleRiskInClosure: WeakMap<WeakKey, true>;
29
+ /** Monotonic generation for dependency-graph materialization/churn. Selector
30
+ * evaluation increments it when a dependency set is created or changed.
31
+ * Orphan teardown only removes edges and deliberately leaves it unchanged:
32
+ * deletion cannot create a cycle, so a synchronous unsubscribe burst can
33
+ * reuse both negative cycle proofs and completed orphan-walk visits. */
34
+ dependencyGraphVersion: number;
35
+ /** `state -> dependencyGraphVersion` for closures proven acyclic. A cached
36
+ * negative remains valid while teardown only deletes edges; any normal graph
37
+ * construction/churn bumps `dependencyGraphVersion` and invalidates it. */
38
+ acyclicDependencyVersion: WeakMap<WeakKey, number>;
39
+ /** `state -> dependencyGraphVersion` for non-live states whose orphan graph
40
+ * work has completed. This is the shared visited set for a deletion-only
41
+ * unsubscribe burst, without retaining states strongly between calls. */
42
+ orphanCleanupVersion: WeakMap<WeakKey, number>;
43
+ /** Strong only until the queued microtask drains it. Batches orphan graph
44
+ * cleanup roots across a synchronous unsubscribe burst. */
45
+ pendingOrphanCleanup?: Set<WeakKey>;
46
+ orphanCleanupScheduled: boolean;
19
47
  mounts: WeakMap<WeakKey, {
20
48
  cleanup?: () => void;
21
49
  }>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "valdres",
3
3
  "description": "Fast atom-based state management for JavaScript. Inspired by Recoil and Jotai.",
4
- "version": "1.0.0-beta.14",
4
+ "version": "1.0.0-beta.15",
5
5
  "license": "MIT",
6
6
  "author": {
7
7
  "name": "Eigil Sagafos"