=0)Z.splice(M,1);Q.flags|=D|L;for(let R=Q.sinks;R;R=R.nextSink)w(R.sink);if(x===0)m()}},replace($,H){let W=X.get($);if(!W)return;if(O(`${_} item for key "${$}"`,H),N(E(()=>W.get()),H))return;if(d(()=>{W.set(H),Q.flags|=D;for(let M=Q.sinks;M;M=M.nextSink)w(M.sink)}),x===0)m()},sort($){let H=[];for(let M of Z){let R=X.get(M)?.get();if(R!==void 0)H.push([M,R])}H.sort(c($)?(M,R)=>$(M[1],R[1]):(M,R)=>String(M[1]).localeCompare(String(R[1])));let W=[];for(let[M]of H)W.push(M);if(!mz(Z,W)){Z=W,Q.flags|=D;for(let M=Q.sinks;M;M=M.nextSink)w(M.sink);if(x===0)m()}},splice($,H,...W){let M=Z.length,R=$<0?Math.max(0,M+$):Math.min($,M),p=Math.max(0,Math.min(H??Math.max(0,M-Math.max(0,R)),M-R)),jz={},C={},F=!1;for(let S=0;SZ(()=>{if(w(X),x===0)m()}):void 0);return{[Symbol.toStringTag]:l,get(){if(B(),Y(X),X.error)throw X.error;return Bz(l,X.value),X.value}}}function pz(z){return A(z,l)}function Uz(z,J){if(h(n,z,Jz),J?.value!==void 0)O(n,J.value,J?.guard);let X={value:!1,sinks:null,sinksTail:null,equals:y},Z={fn:z,value:J?.value,sources:null,sourcesTail:null,sinks:null,sinksTail:null,flags:D,equals:J?.equals??y,controller:void 0,error:void 0,stop:void 0,pendingNode:X},B=J?.watched,U=v(Z,B?()=>B(()=>{if(w(Z),x===0)m()}):void 0),N=v(X);return{[Symbol.toStringTag]:n,get(){if(U(),Y(Z),Z.error)throw Z.error;return Bz(n,Z.value),Z.value},isPending(){return N(),Z.pendingNode.value},abort(){Z.controller?.abort(),Z.controller=void 0,g(Z.pendingNode,!1)}}}function Vz(z){return A(z,n)}function Az(z,J){h(t,J);let X=Jz(J),Z=new Map,B=[],U=($)=>{let H=X?Uz(async(W,M)=>{let R=E(()=>z.byKey($));if(!R)return W;let p=R.get();if(p==null)return W;return J(p,M)}):Mz(()=>{let W=E(()=>z.byKey($));if(!W)return;let M=W.get();if(M==null)return;return J(M)});Z.set($,H)};function N($){if(!mz(B,$)){let H=new Set($);for(let W of B)if(!H.has(W))Z.delete(W);for(let W of $)if(!Z.has(W))U(W);B=$,Q.flags|=L}}function V(){N(Array.from(z.keys()));let $=[];for(let H of B)try{let W=Z.get(H)?.get();if(W!=null)$.push(W)}catch(W){if(!(W instanceof Xz))throw W}return $}let Q={fn:V,value:[],flags:D,sources:null,sourcesTail:null,sinks:null,sinksTail:null,equals:($,H)=>{if($.length!==H.length)return!1;for(let W=0;W<$.length;W++)if($[W]!==H[W])return!1;return!0},error:void 0};function K(){if(Q.sources){if(Q.flags)if(Q.value=E(V),Q.flags&L){if(Q.flags=D,Y(Q),Q.error)throw Q.error}else Q.flags=T}else if(Q.sinks){if(Y(Q),Q.error)throw Q.error}else Q.value=E(V)}let j=Array.from(E(()=>z.keys()));for(let $ of j)U($);B=j;let q={[Symbol.toStringTag]:t,[Symbol.isConcatSpreadable]:!0,*[Symbol.iterator](){if(G)f(Q,G);K();for(let $ of B){let H=Z.get($);if(H)yield H}},get length(){if(G)f(Q,G);return K(),B.length},keys(){if(G)f(Q,G);return K(),B.values()},get(){if(G)f(Q,G);return K(),Q.value},at($){if(G)f(Q,G);K();let H=B[$];return H!==void 0?Z.get(H):void 0},byKey($){if(G)f(Q,G);return K(),Z.get($)},keyAt($){if(G)f(Q,G);return K(),B[$]},indexOfKey($){if(G)f(Q,G);return K(),B.indexOf($)},deriveCollection($){return Az(q,$)}};return q}function jJ(z,J){let X=J?.value??[];if(X.length)O(t,X,Array.isArray);h(t,z,Wz);let Z=new Map,B=[],U=new Map,[N,V]=hz(J?.keyConfig),P=(W)=>U.get(W)??(V?N(W):void 0),Q=J?.createItem??((W)=>o(W,{equals:J?.itemEquals??s}));function K(){let W=[];for(let M of B)try{let R=Z.get(M)?.get();if(R!=null)W.push(R)}catch(R){if(!(R instanceof Xz))throw R}return W}let j={fn:K,value:X,flags:D,sources:null,sourcesTail:null,sinks:null,sinksTail:null,equals:Ez,error:void 0};for(let W of X){let M=N(W);Z.set(M,Q(W)),U.set(W,M),B.push(M)}j.value=X,j.flags=D;let q=(W)=>{let{add:M,change:R,remove:p}=W;if(!M?.length&&!R?.length&&!p?.length)return;let jz=!1;d(()=>{if(M){let C=new Map;for(let F of M){let I=N(F);if(Z.has(I)||C.has(I))throw new u(t,I,F);C.set(I,F)}for(let[F,I]of C){if(Z.set(F,Q(I)),U.set(I,F),!B.includes(F))B.push(F);jz=!0}}if(R)for(let C of R){let F=P(C);if(!F)continue;let I=Z.get(F);if(I&&Gz(I))U.delete(I.get()),I.set(C),U.set(C,F)}if(p)for(let C of p){let F=P(C);if(!F)continue;U.delete(C),Z.delete(F);let I=B.indexOf(F);if(I!==-1)B.splice(I,1);jz=!0}j.flags=D|(jz?L:0);for(let C=j.sinks;C;C=C.nextSink)w(C.sink)})},$=v(j,()=>z(q)),H={[Symbol.toStringTag]:t,[Symbol.isConcatSpreadable]:!0,*[Symbol.iterator](){$();for(let W of B){let M=Z.get(W);if(M)yield M}},get length(){return $(),B.length},keys(){return $(),B.values()},get(){if($(),j.sources){if(j.flags){let W=j.flags&L;if(j.value=E(K),W){if(j.flags=D,Y(j),j.error)throw j.error}else j.flags=T}}else if(Y(j),j.error)throw j.error;return j.value},at(W){$();let M=B[W];return M!==void 0?Z.get(M):void 0},byKey(W){return $(),Z.get(W)},keyAt(W){return $(),B[W]},indexOfKey(W){return $(),B.indexOf(W)},deriveCollection(W){return Az(H,W)}};return H}function WJ(z){return A(z,t)}function HJ(z){h("Effect",z);let J={fn:z,flags:D,sources:null,sourcesTail:null,cleanup:null},X=()=>{Yz(J),J.fn=void 0,J.flags=T,J.sourcesTail=null,Qz(J)};if(b)$z(b,X);return Sz(J),X}function BJ(z,J){if(!b)throw new xz("match");let X=!Array.isArray(z),Z=X?[z]:z,{nil:B,stale:U}=J,N=X?(q)=>J.ok(q[0]):(q)=>J.ok(q),V=X&&J.err?(q)=>J.err(q[0]):J.err??console.error,P,Q=!1,K=Array(Z.length);for(let q=0;qVz(q)&&q.isPending())))j=U();else j=N(K)}catch(q){j=V([q instanceof Error?q:Error(String(q))])}if(typeof j==="function")return j;if(j instanceof Promise){let q=b,$=new AbortController;$z(q,()=>$.abort()),j.then((H)=>{if(!$.signal.aborted&&typeof H==="function")$z(q,H)}).catch((H)=>{V([H instanceof Error?H:Error(String(H))])})}}function QJ(z,J){if(h(a,z,Wz),J?.value!==void 0)O(a,J.value,J?.guard);let X={value:J?.value,sinks:null,sinksTail:null,equals:J?.equals??y,guard:J?.guard,stop:void 0};return{[Symbol.toStringTag]:a,get(){if(G){if(!X.sinks)X.stop=z((Z)=>{O(a,Z,X.guard),g(X,Z)});f(X,G)}return Bz(a,X.value),X.value}}}function qJ(z){return A(z,a)}function MJ(z,J){let X={},Z={},B={},U=!1,N=Object.keys(z),V=Object.keys(J);for(let P of V)if(P in z){if(!s(z[P],J[P]))Z[P]=J[P],U=!0}else X[P]=J[P],U=!0;for(let P of N)if(!(P in J))B[P]=void 0,U=!0;return{add:X,change:Z,remove:B,changed:U}}function Rz(z,J){O(e,z,k);let X=new Map,Z=(j,q)=>{if(O(`${e} for key "${j}"`,q),Array.isArray(q))X.set(j,qz(q));else if(k(q))X.set(j,Rz(q));else X.set(j,o(q))},B=(j)=>{if(Array.isArray(j))return"list";if(k(j))return"store";return"state"},U=(j)=>{if(Pz(j))return"list";if(fz(j))return"store";return"state"},N=()=>{let j={};for(let[q,$]of X)j[q]=$.get();return j},V={fn:N,value:z,flags:D,sources:null,sourcesTail:null,sinks:null,sinksTail:null,equals:s,error:void 0},P=(j)=>{let q=!1;for(let H in j.add)Z(H,j.add[H]),q=!0;let $=!1;for(let H in j.change){$=!0;break}if($)d(()=>{for(let H in j.change){let W=j.change[H];O(`${e} for key "${H}"`,W);let M=X.get(H);if(M)if(B(W)!==U(M))Z(H,W),q=!0;else M.set(W)}});for(let H in j.remove)X.delete(H),q=!0;if(q)V.flags|=L;return j.changed},Q=v(V,J?.watched);for(let j of Object.keys(z))Z(j,z[j]);let K={[Symbol.toStringTag]:e,[Symbol.isConcatSpreadable]:!1,*[Symbol.iterator](){Q();for(let[j,q]of X)yield[j,q]},keys(){return Q(),X.keys()},byKey(j){return X.get(j)},get(){if(Q(),V.sources){if(V.flags){let j=V.flags&L;if(V.value=E(N),j){if(V.flags=D,Y(V),V.error)throw V.error}else V.flags=T}}else if(Y(V),V.error)throw V.error;return V.value},set(j){let q=V.flags&D?E(N):V.value,$=MJ(q,j);if(P($)){V.flags|=D;for(let H=V.sinks;H;H=H.nextSink)w(H.sink);if(x===0)m()}},update(j){K.set(j(K.get()))},add(j,q){if(X.has(j))throw new u(e,j,q);Z(j,q),V.flags|=D|L;for(let $=V.sinks;$;$=$.nextSink)w($.sink);if(x===0)m();return j},remove(j){if(X.delete(j)){V.flags|=D|L;for(let $=V.sinks;$;$=$.nextSink)w($.sink);if(x===0)m()}}};return new Proxy(K,{get(j,q){if(q in j)return Reflect.get(j,q);if(typeof q!=="symbol")return j.byKey(q)},has(j,q){if(q in j)return!0;return j.byKey(String(q))!==void 0},ownKeys(j){return Array.from(j.keys())},getOwnPropertyDescriptor(j,q){if(q in j)return Reflect.getOwnPropertyDescriptor(j,q);if(typeof q==="symbol")return;let $=j.byKey(String(q));return $?{enumerable:!0,configurable:!0,writable:!0,value:$}:void 0}})}function fz(z){return A(z,e)}var UJ=new Set([r,l,n,a,zz,_,t,e]);function VJ(z,J){return Jz(z)?Uz(z,J):Mz(z,J)}function NJ(z){if(Kz(z))return z;if(z==null)throw new Hz("createSignal",z);if(Jz(z))return Uz(z);if(c(z))return Mz(z);if(Array.isArray(z)&&z.every((J)=>J!=null))return qz(z);if(k(z))return Rz(z);return o(z)}function DJ(z){if(lz(z))return z;if(z==null||c(z)||Kz(z))throw new Hz("createMutableSignal",z);if(Array.isArray(z)&&z.every((J)=>J!=null))return qz(z);if(k(z))return Rz(z);return o(z)}function GJ(z){return pz(z)||Vz(z)}function Kz(z){return z!=null&&UJ.has(z[Symbol.toStringTag])}function lz(z){return Gz(z)||fz(z)||Pz(z)}var yz=new WeakSet;function tz(z){if(Kz(z))return!0;return z!==null&&typeof z==="object"&&"get"in z&&typeof z.get==="function"}function PJ(z,J){O(zz,z,tz);let X=z,Z=J?.guard,B={fn:()=>X.get(),value:void 0,flags:D,sources:null,sourcesTail:null,sinks:null,sinksTail:null,equals:J?.equals??y,error:void 0},U=()=>{if(G)f(B,G);if(Y(B),B.error)throw B.error;return B.value},N=(P)=>{if(yz.has(B))throw Error("[Slot] Circular delegation detected in set()");yz.add(B);try{if(sz(X))return void X.set(P);if("set"in X&&typeof X.set==="function")O(zz,P,Z),X.set(P);else throw new Cz(zz)}finally{yz.delete(B)}},V=(P)=>{O(zz,P,tz),X=P,B.flags|=D;for(let Q=B.sinks;Q;Q=Q.nextSink)w(Q.sink);if(x===0)m()};return{[Symbol.toStringTag]:zz,configurable:!0,enumerable:!0,get:U,set:N,replace:V,current:()=>X}}function sz(z){return A(z,zz)}export{Oz as valueString,E as untrack,XJ as unown,BJ as match,Vz as isTask,fz as isStore,Gz as isState,sz as isSlot,A as isSignalOfType,Kz as isSignal,qJ as isSensor,k as isRecord,oz as isObjectOfType,lz as isMutableSignal,pz as isMemo,Pz as isList,c as isFunction,rz as isEqual,GJ as isComputed,WJ as isCollection,Jz as isAsyncFunction,Uz as createTask,Rz as createStore,o as createState,PJ as createSlot,NJ as createSignal,QJ as createSensor,JJ as createScope,DJ as createMutableSignal,Mz as createMemo,qz as createList,HJ as createEffect,VJ as createComputed,jJ as createCollection,d as batch,Xz as UnsetSignalValueError,Ez as SKIP_EQUALITY,xz as RequiredOwnerError,Cz as ReadonlySignalError,Fz as PromiseValueError,Nz as NullishSignalValueError,Hz as InvalidSignalValueError,Lz as InvalidCallbackError,u as DuplicateKeyError,y as DEFAULT_EQUALITY,s as DEEP_EQUALITY,wz as CircularDependencyError};
+// src/util.ts
+var ASYNC_FUNCTION_PROTO = Object.getPrototypeOf(async () => {});
+function isFunction(fn) {
+ return typeof fn === "function";
+}
+function isAsyncFunction(fn) {
+ return isFunction(fn) && Object.getPrototypeOf(fn) === ASYNC_FUNCTION_PROTO;
+}
+function isSyncFunction(fn) {
+ return isFunction(fn) && Object.getPrototypeOf(fn) !== ASYNC_FUNCTION_PROTO;
+}
+function isObjectOfType(value, type) {
+ return Object.prototype.toString.call(value) === `[object ${type}]`;
+}
+function isSignalOfType(value, type) {
+ return value != null && value[Symbol.toStringTag] === type;
+}
+function isRecord(value) {
+ return value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
+}
+function valueString(value) {
+ if (typeof value === "string")
+ return `"${value}"`;
+ if (value != null && typeof value === "object") {
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return String(value);
+ }
+ }
+ return String(value);
+}
+
+// src/errors.ts
+class CircularDependencyError extends Error {
+ constructor(where) {
+ super(`[${where}] Circular dependency detected`);
+ this.name = "CircularDependencyError";
+ }
+}
+
+class EffectConvergenceError extends Error {
+ constructor(passes) {
+ super(`[Effect] Effects did not settle after ${passes} flush passes — check for effects that write to signals they depend on`);
+ this.name = "EffectConvergenceError";
+ }
+}
+
+class NullishSignalValueError extends TypeError {
+ constructor(where) {
+ super(`[${where}] Signal value cannot be null or undefined`);
+ this.name = "NullishSignalValueError";
+ }
+}
+
+class UnsetSignalValueError extends Error {
+ constructor(where) {
+ super(`[${where}] Signal value is unset`);
+ this.name = "UnsetSignalValueError";
+ }
+}
+
+class InvalidSignalValueError extends TypeError {
+ constructor(where, value) {
+ super(`[${where}] Signal value ${valueString(value)} is invalid`);
+ this.name = "InvalidSignalValueError";
+ }
+}
+
+class InvalidCallbackError extends TypeError {
+ constructor(where, value) {
+ super(`[${where}] Callback ${valueString(value)} is invalid`);
+ this.name = "InvalidCallbackError";
+ }
+}
+
+class ReadonlySignalError extends Error {
+ constructor(where) {
+ super(`[${where}] Signal is read-only`);
+ this.name = "ReadonlySignalError";
+ }
+}
+
+class RequiredOwnerError extends Error {
+ constructor(where) {
+ super(`[${where}] Active owner is required`);
+ this.name = "RequiredOwnerError";
+ }
+}
+
+class PromiseValueError extends TypeError {
+ constructor(where) {
+ super(`[${where}] Callback returned a Promise — use an async callback to create a Task instead`);
+ this.name = "PromiseValueError";
+ }
+}
+
+class DuplicateKeyError extends Error {
+ constructor(where, key, value) {
+ super(`[${where}] Could not add key "${key}"${value != null ? ` with value ${JSON.stringify(value)}` : ""} because it already exists`);
+ this.name = "DuplicateKeyError";
+ }
+}
+
+class InvalidStoreMutationError extends TypeError {
+ constructor(prop, action) {
+ const guidance = action === "delete" ? `use store.remove(${JSON.stringify(prop)})` : `use store.${prop}.set(value), store.set(next), or store.add(key, value)`;
+ super(`[Store] Cannot ${action} property "${prop}" directly — ${guidance}`);
+ this.name = "InvalidStoreMutationError";
+ }
+}
+function validateSignalValue(where, value, guard) {
+ if (value == null)
+ throw new NullishSignalValueError(where);
+ if (guard && !guard(value))
+ throw new InvalidSignalValueError(where, value);
+}
+function validateReadValue(where, value) {
+ if (value == null)
+ throw new UnsetSignalValueError(where);
+}
+function validateCallback(where, value, guard = isFunction) {
+ if (!guard(value))
+ throw new InvalidCallbackError(where, value);
+}
+// src/graph.ts
+var TYPE_STATE = "State";
+var TYPE_MEMO = "Memo";
+var TYPE_TASK = "Task";
+var TYPE_SENSOR = "Sensor";
+var TYPE_LIST = "List";
+var TYPE_COLLECTION = "Collection";
+var TYPE_STORE = "Store";
+var TYPE_SLOT = "Slot";
+var FLAG_CLEAN = 0;
+var FLAG_CHECK = 1 << 0;
+var FLAG_DIRTY = 1 << 1;
+var FLAG_RUNNING = 1 << 2;
+var FLAG_RELINK = 1 << 3;
+var activeSink = null;
+var activeOwner = null;
+var queuedEffects = [];
+var batchDepth = 0;
+var flushing = false;
+var DEFAULT_EQUALITY = (a, b) => a === b;
+var SKIP_EQUALITY = (_a, _b) => false;
+var deepEqual = (a, b) => deepEqualInner(a, b, new WeakSet);
+var deepEqualInner = (a, b, seen) => {
+ if (Object.is(a, b))
+ return true;
+ if (typeof a !== typeof b)
+ return false;
+ if (a == null || typeof a !== "object" || b == null || typeof b !== "object")
+ return false;
+ if (seen.has(a))
+ return true;
+ seen.add(a);
+ try {
+ const aIsArray = Array.isArray(a);
+ if (aIsArray !== Array.isArray(b))
+ return false;
+ if (aIsArray) {
+ const aa = a;
+ const ba = b;
+ if (aa.length !== ba.length)
+ return false;
+ for (let i = 0;i < aa.length; i++)
+ if (!deepEqualInner(aa[i], ba[i], seen))
+ return false;
+ return true;
+ }
+ if (a instanceof Date && b instanceof Date)
+ return a.getTime() === b.getTime();
+ if (a instanceof RegExp && b instanceof RegExp)
+ return a.source === b.source && a.flags === b.flags;
+ if (isRecord(a) && isRecord(b)) {
+ const aKeys = Object.keys(a);
+ if (aKeys.length !== Object.keys(b).length)
+ return false;
+ for (const key of aKeys) {
+ if (!(key in b))
+ return false;
+ if (!deepEqualInner(a[key], b[key], seen))
+ return false;
+ }
+ return true;
+ }
+ return false;
+ } finally {
+ seen.delete(a);
+ }
+};
+var DEEP_EQUALITY = (a, b) => deepEqual(a, b);
+var isEqual = DEEP_EQUALITY;
+function isValidEdge(checkEdge, node) {
+ const sourcesTail = node.sourcesTail;
+ if (sourcesTail) {
+ let edge = node.sources;
+ while (edge) {
+ if (edge === checkEdge)
+ return true;
+ if (edge === sourcesTail)
+ break;
+ edge = edge.nextSource;
+ }
+ }
+ return false;
+}
+function link(source, sink) {
+ const prevSource = sink.sourcesTail;
+ if (prevSource?.source === source)
+ return;
+ let nextSource = null;
+ const isRecomputing = sink.flags & FLAG_RUNNING;
+ if (isRecomputing) {
+ nextSource = prevSource ? prevSource.nextSource : sink.sources;
+ if (nextSource?.source === source) {
+ sink.sourcesTail = nextSource;
+ return;
+ }
+ }
+ const prevSink = source.sinksTail;
+ if (prevSink?.sink === sink && (!isRecomputing || isValidEdge(prevSink, sink)))
+ return;
+ const newEdge = { source, sink, nextSource, prevSink, nextSink: null };
+ sink.sourcesTail = source.sinksTail = newEdge;
+ if (prevSource)
+ prevSource.nextSource = newEdge;
+ else
+ sink.sources = newEdge;
+ if (prevSink)
+ prevSink.nextSink = newEdge;
+ else
+ source.sinks = newEdge;
+}
+function unlink(edge) {
+ const { source, nextSource, nextSink, prevSink } = edge;
+ if (nextSink)
+ nextSink.prevSink = prevSink;
+ else
+ source.sinksTail = prevSink;
+ if (prevSink)
+ prevSink.nextSink = nextSink;
+ else
+ source.sinks = nextSink;
+ if (!source.sinks) {
+ if (source.stop) {
+ source.stop();
+ source.stop = undefined;
+ }
+ if ("sources" in source && source.sources) {
+ const sinkNode = source;
+ sinkNode.sourcesTail = null;
+ trimSources(sinkNode);
+ sinkNode.flags |= FLAG_DIRTY;
+ }
+ }
+ return nextSource;
+}
+function trimSources(node) {
+ const tail = node.sourcesTail;
+ let source = tail ? tail.nextSource : node.sources;
+ while (source)
+ source = unlink(source);
+ if (tail)
+ tail.nextSource = null;
+ else
+ node.sources = null;
+}
+function propagate(node, newFlag = FLAG_DIRTY) {
+ const flags = node.flags;
+ if ("sinks" in node) {
+ if ((flags & (FLAG_DIRTY | FLAG_CHECK)) >= newFlag)
+ return;
+ node.flags = flags | newFlag;
+ if ("controller" in node && node.controller) {
+ node.controller.abort();
+ node.controller = undefined;
+ }
+ for (let e = node.sinks;e; e = e.nextSink)
+ propagate(e.sink, FLAG_CHECK);
+ } else {
+ if ((flags & (FLAG_DIRTY | FLAG_CHECK)) >= newFlag)
+ return;
+ const wasQueued = flags & (FLAG_DIRTY | FLAG_CHECK);
+ node.flags = flags & FLAG_RUNNING | newFlag;
+ if (!wasQueued)
+ queuedEffects.push(node);
+ }
+}
+function setState(node, next) {
+ if (node.equals(node.value, next))
+ return;
+ node.value = next;
+ for (let e = node.sinks;e; e = e.nextSink)
+ propagate(e.sink);
+ if (batchDepth === 0)
+ flush();
+}
+function registerCleanup(owner, fn) {
+ if (!owner.cleanup)
+ owner.cleanup = fn;
+ else if (Array.isArray(owner.cleanup))
+ owner.cleanup.push(fn);
+ else
+ owner.cleanup = [owner.cleanup, fn];
+}
+function runCleanup(owner) {
+ if (!owner.cleanup)
+ return;
+ if (Array.isArray(owner.cleanup))
+ for (let i = 0;i < owner.cleanup.length; i++)
+ owner.cleanup[i]();
+ else
+ owner.cleanup();
+ owner.cleanup = null;
+}
+function recomputeMemo(node) {
+ const prevWatcher = activeSink;
+ activeSink = node;
+ node.sourcesTail = null;
+ node.flags = FLAG_RUNNING;
+ let changed = false;
+ try {
+ const next = node.fn(node.value);
+ if (next instanceof Promise)
+ throw new PromiseValueError(TYPE_MEMO);
+ if (node.error || !node.equals(next, node.value)) {
+ node.value = next;
+ node.error = undefined;
+ changed = true;
+ }
+ } catch (err) {
+ changed = true;
+ node.error = err instanceof Error ? err : new Error(String(err));
+ } finally {
+ activeSink = prevWatcher;
+ trimSources(node);
+ }
+ if (changed) {
+ for (let e = node.sinks;e; e = e.nextSink)
+ if (e.sink.flags & FLAG_CHECK)
+ e.sink.flags |= FLAG_DIRTY;
+ }
+ node.flags = FLAG_CLEAN;
+}
+function recomputeTask(node) {
+ node.controller?.abort();
+ const controller = new AbortController;
+ node.controller = controller;
+ node.error = undefined;
+ const prevWatcher = activeSink;
+ activeSink = node;
+ node.sourcesTail = null;
+ node.flags = FLAG_RUNNING;
+ let promise;
+ try {
+ promise = node.fn(node.value, controller.signal);
+ } catch (err) {
+ node.controller = undefined;
+ node.error = err instanceof Error ? err : new Error(String(err));
+ node.flags = FLAG_CLEAN;
+ setState(node.pendingNode, false);
+ return;
+ } finally {
+ activeSink = prevWatcher;
+ trimSources(node);
+ }
+ setState(node.pendingNode, true);
+ promise.then((next) => {
+ if (controller.signal.aborted)
+ return;
+ node.controller = undefined;
+ batch(() => {
+ if (node.error || !node.equals(next, node.value)) {
+ node.value = next;
+ node.error = undefined;
+ for (let e = node.sinks;e; e = e.nextSink)
+ propagate(e.sink);
+ }
+ setState(node.pendingNode, false);
+ });
+ }, (err) => {
+ if (controller.signal.aborted)
+ return;
+ node.controller = undefined;
+ const error = err instanceof Error ? err : new Error(String(err));
+ batch(() => {
+ if (!node.error || error.name !== node.error.name || error.message !== node.error.message) {
+ node.error = error;
+ for (let e = node.sinks;e; e = e.nextSink)
+ propagate(e.sink);
+ }
+ setState(node.pendingNode, false);
+ });
+ });
+ node.flags = FLAG_CLEAN;
+}
+function runEffect(node) {
+ runCleanup(node);
+ const prevContext = activeSink;
+ const prevOwner = activeOwner;
+ activeSink = activeOwner = node;
+ node.sourcesTail = null;
+ node.flags = FLAG_RUNNING;
+ try {
+ const out = node.fn();
+ if (typeof out === "function")
+ registerCleanup(node, out);
+ } finally {
+ activeSink = prevContext;
+ activeOwner = prevOwner;
+ trimSources(node);
+ node.flags &= FLAG_DIRTY | FLAG_CHECK;
+ }
+}
+function refresh(node) {
+ if (node.flags & FLAG_CHECK) {
+ for (let e = node.sources;e; e = e.nextSource) {
+ if ("fn" in e.source)
+ refresh(e.source);
+ if (node.flags & FLAG_DIRTY)
+ break;
+ }
+ }
+ if (node.flags & FLAG_RUNNING) {
+ throw new CircularDependencyError("controller" in node ? TYPE_TASK : ("value" in node) ? TYPE_MEMO : "Effect");
+ }
+ if (node.flags & FLAG_DIRTY) {
+ if ("controller" in node)
+ recomputeTask(node);
+ else if ("value" in node)
+ recomputeMemo(node);
+ else
+ runEffect(node);
+ } else {
+ node.flags = FLAG_CLEAN;
+ }
+}
+var MAX_FLUSH_PASSES = 1000;
+function flush() {
+ if (flushing)
+ return;
+ flushing = true;
+ let errors;
+ let passes = 0;
+ try {
+ while (queuedEffects.length > 0) {
+ if (++passes > MAX_FLUSH_PASSES) {
+ queuedEffects.length = 0;
+ if (!errors)
+ errors = [];
+ errors.push(new EffectConvergenceError(MAX_FLUSH_PASSES));
+ break;
+ }
+ const batch = queuedEffects.slice();
+ queuedEffects.length = 0;
+ for (let i = 0;i < batch.length; i++) {
+ const effect = batch[i];
+ if (effect.flags & FLAG_RUNNING)
+ continue;
+ if (effect.flags & (FLAG_DIRTY | FLAG_CHECK)) {
+ try {
+ refresh(effect);
+ } catch (err) {
+ if (!errors)
+ errors = [];
+ errors.push(err);
+ }
+ }
+ }
+ }
+ } finally {
+ flushing = false;
+ }
+ if (errors) {
+ if (errors.length === 1)
+ throw errors[0];
+ throw new AggregateError(errors, "Multiple effects threw during flush");
+ }
+}
+function scheduleEffect(node) {
+ if (node.flags & (FLAG_DIRTY | FLAG_CHECK)) {
+ queuedEffects.push(node);
+ if (batchDepth === 0)
+ flush();
+ }
+}
+function batch(fn) {
+ batchDepth++;
+ try {
+ fn();
+ } finally {
+ batchDepth--;
+ if (batchDepth === 0)
+ flush();
+ }
+}
+function untrack(fn) {
+ const prev = activeSink;
+ activeSink = null;
+ try {
+ return fn();
+ } finally {
+ activeSink = prev;
+ }
+}
+function createScope(fn, options) {
+ const prevOwner = activeOwner;
+ const scope = { cleanup: null };
+ activeOwner = scope;
+ const dispose = () => runCleanup(scope);
+ try {
+ const out = fn();
+ if (typeof out === "function")
+ registerCleanup(scope, out);
+ return dispose;
+ } finally {
+ activeOwner = prevOwner;
+ if (!options?.root && prevOwner)
+ registerCleanup(prevOwner, dispose);
+ }
+}
+function unown(fn) {
+ const prev = activeOwner;
+ activeOwner = null;
+ try {
+ return fn();
+ } finally {
+ activeOwner = prev;
+ }
+}
+function makeSubscribe(node, onWatch) {
+ return onWatch ? () => {
+ if (activeSink) {
+ if (!node.sinks)
+ node.stop = onWatch();
+ link(node, activeSink);
+ }
+ } : () => {
+ if (activeSink)
+ link(node, activeSink);
+ };
+}
+// src/nodes/state.ts
+function createState(value, options) {
+ validateSignalValue(TYPE_STATE, value, options?.guard);
+ const node = {
+ value,
+ sinks: null,
+ sinksTail: null,
+ equals: options?.equals ?? DEFAULT_EQUALITY,
+ guard: options?.guard
+ };
+ return {
+ [Symbol.toStringTag]: TYPE_STATE,
+ get() {
+ if (activeSink)
+ link(node, activeSink);
+ return node.value;
+ },
+ set(next) {
+ validateSignalValue(TYPE_STATE, next, node.guard);
+ setState(node, next);
+ },
+ update(fn) {
+ validateCallback(TYPE_STATE, fn);
+ const next = fn(node.value);
+ validateSignalValue(TYPE_STATE, next, node.guard);
+ setState(node, next);
+ }
+ };
+}
+function isState(value) {
+ return isSignalOfType(value, TYPE_STATE);
+}
+
+// src/nodes/list.ts
+function keysEqual(a, b) {
+ if (a.length !== b.length)
+ return false;
+ for (let i = 0;i < a.length; i++)
+ if (a[i] !== b[i])
+ return false;
+ return true;
+}
+function getKeyGenerator(keyConfig) {
+ let keyCounter = 0;
+ const contentBased = typeof keyConfig === "function";
+ return [
+ typeof keyConfig === "string" ? () => `${keyConfig}${keyCounter++}` : contentBased ? (item) => keyConfig(item) || String(keyCounter++) : () => String(keyCounter++),
+ contentBased
+ ];
+}
+function diffPositional(prev, next, prevKeys, generateKey, itemEquals) {
+ const add = {};
+ const change = {};
+ const remove = {};
+ const nextKeys = [];
+ let changed = false;
+ const minLen = Math.min(prev.length, next.length);
+ for (let i = 0;i < minLen; i++) {
+ const key = prevKeys[i];
+ nextKeys.push(key);
+ if (!itemEquals(prev[i], next[i])) {
+ change[key] = next[i];
+ changed = true;
+ }
+ }
+ for (let i = minLen;i < next.length; i++) {
+ const val = next[i];
+ const key = generateKey(val);
+ nextKeys.push(key);
+ add[key] = val;
+ changed = true;
+ }
+ for (let i = minLen;i < prev.length; i++) {
+ remove[prevKeys[i]] = null;
+ changed = true;
+ }
+ return { add, change, remove, newKeys: nextKeys, changed };
+}
+function diffArrays(prev, next, prevKeys, generateKey, contentBased, itemEquals) {
+ if (!contentBased)
+ return diffPositional(prev, next, prevKeys, generateKey, itemEquals);
+ const add = {};
+ const change = {};
+ const remove = {};
+ const nextKeys = [];
+ let changed = false;
+ const prevByKey = new Map;
+ for (let i = 0;i < prev.length; i++) {
+ const key = prevKeys[i];
+ const item = prev[i];
+ if (key && item !== undefined)
+ prevByKey.set(key, item);
+ }
+ const seenKeys = new Set;
+ for (let i = 0;i < next.length; i++) {
+ const val = next[i];
+ validateSignalValue(`${TYPE_LIST} item at index ${i}`, val);
+ const key = generateKey(val);
+ if (seenKeys.has(key))
+ throw new DuplicateKeyError(TYPE_LIST, key, val);
+ nextKeys.push(key);
+ seenKeys.add(key);
+ if (!prevByKey.has(key)) {
+ add[key] = val;
+ changed = true;
+ } else if (!itemEquals(prevByKey.get(key), val)) {
+ change[key] = val;
+ changed = true;
+ }
+ }
+ for (const [key] of prevByKey) {
+ if (!seenKeys.has(key)) {
+ remove[key] = null;
+ changed = true;
+ }
+ }
+ if (!changed && !keysEqual(prevKeys, nextKeys))
+ changed = true;
+ return { add, change, remove, newKeys: nextKeys, changed };
+}
+function createList(value, options) {
+ validateSignalValue(TYPE_LIST, value, Array.isArray);
+ const signals = new Map;
+ let keys = [];
+ const [generateKey, contentBased] = getKeyGenerator(options?.keyConfig);
+ const itemEquals = options?.itemEquals ?? DEEP_EQUALITY;
+ const itemFactory = options?.createItem ?? ((item) => createState(item, { equals: itemEquals }));
+ const buildValue = () => {
+ const result = [];
+ for (const key of keys) {
+ const v = signals.get(key)?.get();
+ if (v !== undefined)
+ result.push(v);
+ }
+ return result;
+ };
+ const node = {
+ fn: buildValue,
+ value,
+ flags: FLAG_DIRTY,
+ sources: null,
+ sourcesTail: null,
+ sinks: null,
+ sinksTail: null,
+ equals: DEEP_EQUALITY,
+ error: undefined
+ };
+ const applyChanges = (changes) => {
+ let structural = false;
+ for (const key in changes.add) {
+ const val = changes.add[key];
+ validateSignalValue(`${TYPE_LIST} item for key "${key}"`, val);
+ signals.set(key, itemFactory(val));
+ structural = true;
+ }
+ let hasChange = false;
+ for (const _key in changes.change) {
+ hasChange = true;
+ break;
+ }
+ if (hasChange) {
+ batch(() => {
+ for (const key in changes.change) {
+ const val = changes.change[key];
+ validateSignalValue(`${TYPE_LIST} item for key "${key}"`, val);
+ const signal = signals.get(key);
+ if (signal)
+ signal.set(val);
+ }
+ });
+ }
+ for (const key in changes.remove) {
+ signals.delete(key);
+ const index = keys.indexOf(key);
+ if (index !== -1)
+ keys.splice(index, 1);
+ structural = true;
+ }
+ if (structural)
+ node.flags |= FLAG_RELINK;
+ return changes.changed;
+ };
+ const subscribe = makeSubscribe(node, options?.watched);
+ for (let i = 0;i < value.length; i++) {
+ const val = value[i];
+ if (val == null)
+ throw new NullishSignalValueError(`${TYPE_LIST} item ${i}`);
+ let key = keys[i];
+ if (!key) {
+ key = generateKey(val);
+ keys[i] = key;
+ }
+ signals.set(key, itemFactory(val));
+ }
+ node.value = value;
+ node.flags = 0;
+ const list = {
+ [Symbol.toStringTag]: TYPE_LIST,
+ [Symbol.isConcatSpreadable]: true,
+ *[Symbol.iterator]() {
+ subscribe();
+ for (const key of keys) {
+ const signal = signals.get(key);
+ if (signal)
+ yield signal;
+ }
+ },
+ get length() {
+ subscribe();
+ return keys.length;
+ },
+ get() {
+ subscribe();
+ if (node.sources) {
+ if (node.flags) {
+ const relink = node.flags & FLAG_RELINK;
+ node.value = untrack(buildValue);
+ if (relink) {
+ node.flags = FLAG_DIRTY;
+ refresh(node);
+ if (node.error)
+ throw node.error;
+ } else {
+ node.flags = FLAG_CLEAN;
+ }
+ }
+ } else {
+ refresh(node);
+ if (node.error)
+ throw node.error;
+ }
+ return node.value;
+ },
+ set(next) {
+ const prev = node.flags & FLAG_DIRTY ? untrack(buildValue) : node.value;
+ const changes = diffArrays(prev, next, keys, generateKey, contentBased, itemEquals);
+ if (changes.changed) {
+ keys = changes.newKeys;
+ applyChanges(changes);
+ node.flags |= FLAG_DIRTY;
+ for (let e = node.sinks;e; e = e.nextSink)
+ propagate(e.sink);
+ if (batchDepth === 0)
+ flush();
+ }
+ },
+ update(fn) {
+ list.set(fn(untrack(() => list.get())));
+ },
+ at(index) {
+ subscribe();
+ const key = keys[index];
+ return key !== undefined ? signals.get(key) : undefined;
+ },
+ keys() {
+ subscribe();
+ return keys.values();
+ },
+ byKey(key) {
+ subscribe();
+ return signals.get(key);
+ },
+ keyAt(index) {
+ subscribe();
+ return keys[index];
+ },
+ indexOfKey(key) {
+ subscribe();
+ return keys.indexOf(key);
+ },
+ add(value2) {
+ const key = generateKey(value2);
+ if (signals.has(key))
+ throw new DuplicateKeyError(TYPE_LIST, key, value2);
+ keys.push(key);
+ validateSignalValue(`${TYPE_LIST} item for key "${key}"`, value2);
+ signals.set(key, itemFactory(value2));
+ node.flags |= FLAG_DIRTY | FLAG_RELINK;
+ for (let e = node.sinks;e; e = e.nextSink)
+ propagate(e.sink);
+ if (batchDepth === 0)
+ flush();
+ return key;
+ },
+ remove(keyOrIndex) {
+ const key = typeof keyOrIndex === "number" ? keys[keyOrIndex] : keyOrIndex;
+ if (key === undefined)
+ return;
+ const ok = signals.delete(key);
+ if (ok) {
+ const index = typeof keyOrIndex === "number" ? keyOrIndex : keys.indexOf(key);
+ if (index >= 0)
+ keys.splice(index, 1);
+ node.flags |= FLAG_DIRTY | FLAG_RELINK;
+ for (let e = node.sinks;e; e = e.nextSink)
+ propagate(e.sink);
+ if (batchDepth === 0)
+ flush();
+ }
+ },
+ replace(key, value2) {
+ const signal = signals.get(key);
+ if (!signal)
+ return;
+ validateSignalValue(`${TYPE_LIST} item for key "${key}"`, value2);
+ if (itemEquals(untrack(() => signal.get()), value2))
+ return;
+ batch(() => {
+ signal.set(value2);
+ node.flags |= FLAG_DIRTY;
+ for (let e = node.sinks;e; e = e.nextSink)
+ propagate(e.sink);
+ });
+ if (batchDepth === 0)
+ flush();
+ },
+ sort(compareFn) {
+ const entries = [];
+ untrack(() => {
+ for (const key of keys) {
+ const v = signals.get(key)?.get();
+ if (v !== undefined)
+ entries.push([key, v]);
+ }
+ });
+ entries.sort(isFunction(compareFn) ? (a, b) => compareFn(a[1], b[1]) : (a, b) => String(a[1]).localeCompare(String(b[1])));
+ const newOrder = [];
+ for (const [key] of entries)
+ newOrder.push(key);
+ if (!keysEqual(keys, newOrder)) {
+ keys = newOrder;
+ node.flags |= FLAG_DIRTY;
+ for (let e = node.sinks;e; e = e.nextSink)
+ propagate(e.sink);
+ if (batchDepth === 0)
+ flush();
+ }
+ },
+ splice(start, deleteCount, ...items) {
+ const length = keys.length;
+ const actualStart = start < 0 ? Math.max(0, length + start) : Math.min(start, length);
+ const actualDeleteCount = Math.max(0, Math.min(deleteCount ?? Math.max(0, length - Math.max(0, actualStart)), length - actualStart));
+ const add = {};
+ const remove = {};
+ let hasRemove = false;
+ untrack(() => {
+ for (let i = 0;i < actualDeleteCount; i++) {
+ const index = actualStart + i;
+ const key = keys[index];
+ if (key) {
+ const signal = signals.get(key);
+ if (signal) {
+ remove[key] = signal.get();
+ hasRemove = true;
+ }
+ }
+ }
+ });
+ const newOrder = keys.slice(0, actualStart);
+ const change = {};
+ let hasAdd = false;
+ let hasChange = false;
+ for (const item of items) {
+ const key = generateKey(item);
+ if (key in remove) {
+ delete remove[key];
+ change[key] = item;
+ hasChange = true;
+ } else if (signals.has(key)) {
+ throw new DuplicateKeyError(TYPE_LIST, key, item);
+ } else {
+ add[key] = item;
+ hasAdd = true;
+ }
+ newOrder.push(key);
+ }
+ newOrder.push(...keys.slice(actualStart + actualDeleteCount));
+ const changed = hasAdd || hasRemove || hasChange;
+ if (changed) {
+ applyChanges({
+ add,
+ change,
+ remove,
+ changed
+ });
+ keys = newOrder;
+ node.flags |= FLAG_DIRTY;
+ for (let e = node.sinks;e; e = e.nextSink)
+ propagate(e.sink);
+ if (batchDepth === 0)
+ flush();
+ }
+ return Object.values(remove);
+ },
+ deriveCollection(cb) {
+ return deriveCollection(list, cb);
+ }
+ };
+ return list;
+}
+function isList(value) {
+ return isSignalOfType(value, TYPE_LIST);
+}
+
+// src/nodes/memo.ts
+function createMemo(fn, options) {
+ validateCallback(TYPE_MEMO, fn, isSyncFunction);
+ if (options?.value !== undefined)
+ validateSignalValue(TYPE_MEMO, options.value, options?.guard);
+ const node = {
+ fn,
+ value: options?.value,
+ flags: FLAG_DIRTY,
+ sources: null,
+ sourcesTail: null,
+ sinks: null,
+ sinksTail: null,
+ equals: options?.equals ?? DEFAULT_EQUALITY,
+ error: undefined,
+ stop: undefined
+ };
+ const watched = options?.watched;
+ const subscribe = makeSubscribe(node, watched ? () => watched(() => {
+ propagate(node);
+ if (batchDepth === 0)
+ flush();
+ }) : undefined);
+ return {
+ [Symbol.toStringTag]: TYPE_MEMO,
+ get() {
+ subscribe();
+ refresh(node);
+ if (node.error)
+ throw node.error;
+ validateReadValue(TYPE_MEMO, node.value);
+ return node.value;
+ }
+ };
+}
+function isMemo(value) {
+ return isSignalOfType(value, TYPE_MEMO);
+}
+
+// src/nodes/task.ts
+function createTask(fn, options) {
+ validateCallback(TYPE_TASK, fn, isAsyncFunction);
+ if (options?.value !== undefined)
+ validateSignalValue(TYPE_TASK, options.value, options?.guard);
+ const pendingNode = {
+ value: false,
+ sinks: null,
+ sinksTail: null,
+ equals: DEFAULT_EQUALITY
+ };
+ const node = {
+ fn,
+ value: options?.value,
+ sources: null,
+ sourcesTail: null,
+ sinks: null,
+ sinksTail: null,
+ flags: FLAG_DIRTY,
+ equals: options?.equals ?? DEFAULT_EQUALITY,
+ controller: undefined,
+ error: undefined,
+ stop: undefined,
+ pendingNode
+ };
+ const watched = options?.watched;
+ const subscribe = makeSubscribe(node, watched ? () => watched(() => {
+ propagate(node);
+ if (batchDepth === 0)
+ flush();
+ }) : undefined);
+ const pendingSubscribe = makeSubscribe(pendingNode);
+ return {
+ [Symbol.toStringTag]: TYPE_TASK,
+ get() {
+ subscribe();
+ refresh(node);
+ if (node.error)
+ throw node.error;
+ validateReadValue(TYPE_TASK, node.value);
+ return node.value;
+ },
+ isPending() {
+ pendingSubscribe();
+ return node.pendingNode.value;
+ },
+ abort() {
+ node.controller?.abort();
+ node.controller = undefined;
+ setState(node.pendingNode, false);
+ }
+ };
+}
+function isTask(value) {
+ return isSignalOfType(value, TYPE_TASK);
+}
+
+// src/nodes/collection.ts
+function deriveCollection(source, callback) {
+ validateCallback(TYPE_COLLECTION, callback);
+ const isAsync = isAsyncFunction(callback);
+ const signals = new Map;
+ let keys = [];
+ const addSignal = (key) => {
+ const signal = isAsync ? createTask(async (prev, abort) => {
+ const itemSignal = untrack(() => source.byKey(key));
+ if (!itemSignal)
+ return prev;
+ const sourceValue = itemSignal.get();
+ if (sourceValue == null)
+ return prev;
+ return callback(sourceValue, abort);
+ }) : createMemo(() => {
+ const itemSignal = untrack(() => source.byKey(key));
+ if (!itemSignal)
+ return;
+ const sourceValue = itemSignal.get();
+ if (sourceValue == null)
+ return;
+ return callback(sourceValue);
+ });
+ signals.set(key, signal);
+ };
+ function syncKeys(nextKeys) {
+ if (!keysEqual(keys, nextKeys)) {
+ const nextSet = new Set(nextKeys);
+ for (const key of keys)
+ if (!nextSet.has(key))
+ signals.delete(key);
+ for (const key of nextKeys)
+ if (!signals.has(key))
+ addSignal(key);
+ keys = nextKeys;
+ node.flags |= FLAG_RELINK;
+ }
+ }
+ function buildValue() {
+ syncKeys(Array.from(source.keys()));
+ const result = [];
+ for (const key of keys) {
+ try {
+ const v = signals.get(key)?.get();
+ if (v != null)
+ result.push(v);
+ } catch (e) {
+ if (!(e instanceof UnsetSignalValueError))
+ throw e;
+ }
+ }
+ return result;
+ }
+ const valuesEqual = (a, b) => {
+ if (a.length !== b.length)
+ return false;
+ for (let i = 0;i < a.length; i++)
+ if (a[i] !== b[i])
+ return false;
+ return true;
+ };
+ const node = {
+ fn: buildValue,
+ value: [],
+ flags: FLAG_DIRTY,
+ sources: null,
+ sourcesTail: null,
+ sinks: null,
+ sinksTail: null,
+ equals: valuesEqual,
+ error: undefined
+ };
+ function ensureFresh() {
+ if (node.sources) {
+ if (node.flags) {
+ node.value = untrack(buildValue);
+ if (node.flags & FLAG_RELINK) {
+ node.flags = FLAG_DIRTY;
+ refresh(node);
+ if (node.error)
+ throw node.error;
+ } else {
+ node.flags = FLAG_CLEAN;
+ }
+ }
+ } else if (node.sinks) {
+ refresh(node);
+ if (node.error)
+ throw node.error;
+ } else {
+ node.value = untrack(buildValue);
+ }
+ }
+ const initialKeys = Array.from(untrack(() => source.keys()));
+ for (const key of initialKeys)
+ addSignal(key);
+ keys = initialKeys;
+ const collection = {
+ [Symbol.toStringTag]: TYPE_COLLECTION,
+ [Symbol.isConcatSpreadable]: true,
+ *[Symbol.iterator]() {
+ if (activeSink)
+ link(node, activeSink);
+ ensureFresh();
+ for (const key of keys) {
+ const signal = signals.get(key);
+ if (signal)
+ yield signal;
+ }
+ },
+ get length() {
+ if (activeSink)
+ link(node, activeSink);
+ ensureFresh();
+ return keys.length;
+ },
+ keys() {
+ if (activeSink)
+ link(node, activeSink);
+ ensureFresh();
+ return keys.values();
+ },
+ get() {
+ if (activeSink)
+ link(node, activeSink);
+ ensureFresh();
+ return node.value;
+ },
+ at(index) {
+ if (activeSink)
+ link(node, activeSink);
+ ensureFresh();
+ const key = keys[index];
+ return key !== undefined ? signals.get(key) : undefined;
+ },
+ byKey(key) {
+ if (activeSink)
+ link(node, activeSink);
+ ensureFresh();
+ return signals.get(key);
+ },
+ keyAt(index) {
+ if (activeSink)
+ link(node, activeSink);
+ ensureFresh();
+ return keys[index];
+ },
+ indexOfKey(key) {
+ if (activeSink)
+ link(node, activeSink);
+ ensureFresh();
+ return keys.indexOf(key);
+ },
+ deriveCollection(cb) {
+ return deriveCollection(collection, cb);
+ }
+ };
+ return collection;
+}
+function createCollection(watched, options) {
+ const value = options?.value ?? [];
+ if (value.length)
+ validateSignalValue(TYPE_COLLECTION, value, Array.isArray);
+ validateCallback(TYPE_COLLECTION, watched, isSyncFunction);
+ const signals = new Map;
+ const keys = [];
+ const itemToKey = new Map;
+ const [generateKey, contentBased] = getKeyGenerator(options?.keyConfig);
+ const resolveKey = (item) => itemToKey.get(item) ?? (contentBased ? generateKey(item) : undefined);
+ const itemFactory = options?.createItem ?? ((item) => createState(item, {
+ equals: options?.itemEquals ?? DEEP_EQUALITY
+ }));
+ function buildValue() {
+ const result = [];
+ for (const key of keys) {
+ try {
+ const v = signals.get(key)?.get();
+ if (v != null)
+ result.push(v);
+ } catch (e) {
+ if (!(e instanceof UnsetSignalValueError))
+ throw e;
+ }
+ }
+ return result;
+ }
+ const node = {
+ fn: buildValue,
+ value,
+ flags: FLAG_DIRTY,
+ sources: null,
+ sourcesTail: null,
+ sinks: null,
+ sinksTail: null,
+ equals: SKIP_EQUALITY,
+ error: undefined
+ };
+ for (const item of value) {
+ const key = generateKey(item);
+ signals.set(key, itemFactory(item));
+ itemToKey.set(item, key);
+ keys.push(key);
+ }
+ node.value = value;
+ node.flags = FLAG_DIRTY;
+ const onChanges = (changes) => {
+ const { add, change, remove } = changes;
+ if (!add?.length && !change?.length && !remove?.length)
+ return;
+ let structural = false;
+ batch(() => {
+ if (add) {
+ const staged = new Map;
+ for (const item of add) {
+ const key = generateKey(item);
+ if (signals.has(key) || staged.has(key))
+ throw new DuplicateKeyError(TYPE_COLLECTION, key, item);
+ staged.set(key, item);
+ }
+ for (const [key, item] of staged) {
+ signals.set(key, itemFactory(item));
+ itemToKey.set(item, key);
+ if (!keys.includes(key))
+ keys.push(key);
+ structural = true;
+ }
+ }
+ if (change) {
+ for (const item of change) {
+ const key = resolveKey(item);
+ if (!key)
+ continue;
+ const signal = signals.get(key);
+ if (signal && isState(signal)) {
+ itemToKey.delete(untrack(() => signal.get()));
+ signal.set(item);
+ itemToKey.set(item, key);
+ }
+ }
+ }
+ if (remove) {
+ for (const item of remove) {
+ const key = resolveKey(item);
+ if (!key)
+ continue;
+ itemToKey.delete(item);
+ signals.delete(key);
+ const index = keys.indexOf(key);
+ if (index !== -1)
+ keys.splice(index, 1);
+ structural = true;
+ }
+ }
+ node.flags = FLAG_DIRTY | (structural ? FLAG_RELINK : 0);
+ for (let e = node.sinks;e; e = e.nextSink)
+ propagate(e.sink);
+ });
+ };
+ const subscribe = makeSubscribe(node, () => watched(onChanges));
+ const collection = {
+ [Symbol.toStringTag]: TYPE_COLLECTION,
+ [Symbol.isConcatSpreadable]: true,
+ *[Symbol.iterator]() {
+ subscribe();
+ for (const key of keys) {
+ const signal = signals.get(key);
+ if (signal)
+ yield signal;
+ }
+ },
+ get length() {
+ subscribe();
+ return keys.length;
+ },
+ keys() {
+ subscribe();
+ return keys.values();
+ },
+ get() {
+ subscribe();
+ if (node.sources) {
+ if (node.flags) {
+ const relink = node.flags & FLAG_RELINK;
+ node.value = untrack(buildValue);
+ if (relink) {
+ node.flags = FLAG_DIRTY;
+ refresh(node);
+ if (node.error)
+ throw node.error;
+ } else {
+ node.flags = FLAG_CLEAN;
+ }
+ }
+ } else {
+ refresh(node);
+ if (node.error)
+ throw node.error;
+ }
+ return node.value;
+ },
+ at(index) {
+ subscribe();
+ const key = keys[index];
+ return key !== undefined ? signals.get(key) : undefined;
+ },
+ byKey(key) {
+ subscribe();
+ return signals.get(key);
+ },
+ keyAt(index) {
+ subscribe();
+ return keys[index];
+ },
+ indexOfKey(key) {
+ subscribe();
+ return keys.indexOf(key);
+ },
+ deriveCollection(cb) {
+ return deriveCollection(collection, cb);
+ }
+ };
+ return collection;
+}
+function isCollection(value) {
+ return isSignalOfType(value, TYPE_COLLECTION);
+}
+// src/nodes/effect.ts
+function createEffect(fn) {
+ validateCallback("Effect", fn);
+ const node = {
+ fn,
+ flags: FLAG_DIRTY,
+ sources: null,
+ sourcesTail: null,
+ cleanup: null
+ };
+ const dispose = () => {
+ runCleanup(node);
+ node.fn = undefined;
+ node.flags = FLAG_CLEAN;
+ node.sourcesTail = null;
+ trimSources(node);
+ };
+ if (activeOwner)
+ registerCleanup(activeOwner, dispose);
+ runEffect(node);
+ scheduleEffect(node);
+ return dispose;
+}
+function match(signalOrSignals, handlers) {
+ if (!activeOwner)
+ throw new RequiredOwnerError("match");
+ const isSingle = !Array.isArray(signalOrSignals);
+ const signals = isSingle ? [signalOrSignals] : signalOrSignals;
+ const { nil, stale } = handlers;
+ const ok = isSingle ? (values2) => handlers.ok(values2[0]) : (values2) => handlers.ok(values2);
+ const err = isSingle && handlers.err ? (errors2) => handlers.err(errors2[0]) : handlers.err ?? console.error;
+ let errors;
+ let pending = false;
+ const values = new Array(signals.length);
+ for (let i = 0;i < signals.length; i++) {
+ try {
+ values[i] = signals[i].get();
+ } catch (e) {
+ if (e instanceof UnsetSignalValueError) {
+ pending = true;
+ continue;
+ }
+ if (!errors)
+ errors = [];
+ errors.push(e instanceof Error ? e : new Error(String(e)));
+ }
+ }
+ let out;
+ try {
+ if (pending)
+ out = nil?.();
+ else if (errors)
+ out = err(errors);
+ else if (stale && (isSingle ? isTask(signals[0]) && signals[0].isPending() : signals.some((s) => isTask(s) && s.isPending())))
+ out = stale();
+ else
+ out = ok(values);
+ } catch (e) {
+ out = err([e instanceof Error ? e : new Error(String(e))]);
+ }
+ if (typeof out === "function")
+ return out;
+ if (out instanceof Promise) {
+ const owner = activeOwner;
+ const controller = new AbortController;
+ registerCleanup(owner, () => controller.abort());
+ out.then((cleanup) => {
+ if (!controller.signal.aborted && typeof cleanup === "function")
+ registerCleanup(owner, cleanup);
+ }).catch((e) => {
+ err([e instanceof Error ? e : new Error(String(e))]);
+ });
+ }
+}
+// src/nodes/sensor.ts
+function createSensor(watched, options) {
+ validateCallback(TYPE_SENSOR, watched, isSyncFunction);
+ if (options?.value !== undefined)
+ validateSignalValue(TYPE_SENSOR, options.value, options?.guard);
+ const node = {
+ value: options?.value,
+ sinks: null,
+ sinksTail: null,
+ equals: options?.equals ?? DEFAULT_EQUALITY,
+ guard: options?.guard,
+ stop: undefined
+ };
+ return {
+ [Symbol.toStringTag]: TYPE_SENSOR,
+ get() {
+ if (activeSink) {
+ if (!node.sinks)
+ node.stop = watched((next) => {
+ validateSignalValue(TYPE_SENSOR, next, node.guard);
+ setState(node, next);
+ });
+ link(node, activeSink);
+ }
+ validateReadValue(TYPE_SENSOR, node.value);
+ return node.value;
+ }
+ };
+}
+function isSensor(value) {
+ return isSignalOfType(value, TYPE_SENSOR);
+}
+// src/nodes/store.ts
+function diffRecords(prev, next) {
+ const add = {};
+ const change = {};
+ const remove = {};
+ let changed = false;
+ const prevKeys = Object.keys(prev);
+ const nextKeys = Object.keys(next);
+ for (const key of nextKeys) {
+ if (key in prev) {
+ if (!DEEP_EQUALITY(prev[key], next[key])) {
+ change[key] = next[key];
+ changed = true;
+ }
+ } else {
+ add[key] = next[key];
+ changed = true;
+ }
+ }
+ for (const key of prevKeys) {
+ if (!(key in next)) {
+ remove[key] = undefined;
+ changed = true;
+ }
+ }
+ return { add, change, remove, changed };
+}
+function createStore(value, options) {
+ validateSignalValue(TYPE_STORE, value, isRecord);
+ const signals = new Map;
+ const addSignal = (key, val) => {
+ validateSignalValue(`${TYPE_STORE} for key "${key}"`, val);
+ if (Array.isArray(val))
+ signals.set(key, createList(val));
+ else if (isRecord(val))
+ signals.set(key, createStore(val));
+ else
+ signals.set(key, createState(val));
+ };
+ const shapeCategory = (val) => {
+ if (Array.isArray(val))
+ return "list";
+ if (isRecord(val))
+ return "store";
+ return "state";
+ };
+ const signalCategory = (signal) => {
+ if (isList(signal))
+ return "list";
+ if (isStore(signal))
+ return "store";
+ return "state";
+ };
+ const buildValue = () => {
+ const record = {};
+ for (const [key, signal] of signals)
+ record[key] = signal.get();
+ return record;
+ };
+ const node = {
+ fn: buildValue,
+ value,
+ flags: FLAG_DIRTY,
+ sources: null,
+ sourcesTail: null,
+ sinks: null,
+ sinksTail: null,
+ equals: DEEP_EQUALITY,
+ error: undefined
+ };
+ const applyChanges = (changes) => {
+ let structural = false;
+ for (const key in changes.add) {
+ addSignal(key, changes.add[key]);
+ structural = true;
+ }
+ let hasChange = false;
+ for (const _key in changes.change) {
+ hasChange = true;
+ break;
+ }
+ if (hasChange) {
+ batch(() => {
+ for (const key in changes.change) {
+ const val = changes.change[key];
+ validateSignalValue(`${TYPE_STORE} for key "${key}"`, val);
+ const signal = signals.get(key);
+ if (signal) {
+ if (shapeCategory(val) !== signalCategory(signal)) {
+ addSignal(key, val);
+ structural = true;
+ } else
+ signal.set(val);
+ }
+ }
+ });
+ }
+ for (const key in changes.remove) {
+ signals.delete(key);
+ structural = true;
+ }
+ if (structural)
+ node.flags |= FLAG_RELINK;
+ return changes.changed;
+ };
+ const subscribe = makeSubscribe(node, options?.watched);
+ for (const key of Object.keys(value))
+ addSignal(key, value[key]);
+ const store = {
+ [Symbol.toStringTag]: TYPE_STORE,
+ [Symbol.isConcatSpreadable]: false,
+ *[Symbol.iterator]() {
+ subscribe();
+ for (const [key, signal] of signals) {
+ yield [key, signal];
+ }
+ },
+ keys() {
+ subscribe();
+ return signals.keys();
+ },
+ byKey(key) {
+ return signals.get(key);
+ },
+ get() {
+ subscribe();
+ if (node.sources) {
+ if (node.flags) {
+ const relink = node.flags & FLAG_RELINK;
+ node.value = untrack(buildValue);
+ if (relink) {
+ node.flags = FLAG_DIRTY;
+ refresh(node);
+ if (node.error)
+ throw node.error;
+ } else {
+ node.flags = FLAG_CLEAN;
+ }
+ }
+ } else {
+ refresh(node);
+ if (node.error)
+ throw node.error;
+ }
+ return node.value;
+ },
+ set(next) {
+ const prev = node.flags & FLAG_DIRTY ? untrack(buildValue) : node.value;
+ const changes = diffRecords(prev, next);
+ if (applyChanges(changes)) {
+ node.flags |= FLAG_DIRTY;
+ for (let e = node.sinks;e; e = e.nextSink)
+ propagate(e.sink);
+ if (batchDepth === 0)
+ flush();
+ }
+ },
+ update(fn) {
+ store.set(fn(untrack(() => store.get())));
+ },
+ add(key, value2) {
+ if (signals.has(key))
+ throw new DuplicateKeyError(TYPE_STORE, key, value2);
+ addSignal(key, value2);
+ node.flags |= FLAG_DIRTY | FLAG_RELINK;
+ for (let e = node.sinks;e; e = e.nextSink)
+ propagate(e.sink);
+ if (batchDepth === 0)
+ flush();
+ return key;
+ },
+ remove(key) {
+ const ok = signals.delete(key);
+ if (ok) {
+ node.flags |= FLAG_DIRTY | FLAG_RELINK;
+ for (let e = node.sinks;e; e = e.nextSink)
+ propagate(e.sink);
+ if (batchDepth === 0)
+ flush();
+ }
+ }
+ };
+ return new Proxy(store, {
+ get(target, prop) {
+ if (prop in target)
+ return Reflect.get(target, prop);
+ if (typeof prop !== "symbol")
+ return target.byKey(prop);
+ },
+ set(_target, prop) {
+ throw new InvalidStoreMutationError(String(prop), "assign to");
+ },
+ deleteProperty(_target, prop) {
+ throw new InvalidStoreMutationError(String(prop), "delete");
+ },
+ defineProperty(_target, prop) {
+ throw new InvalidStoreMutationError(String(prop), "define");
+ },
+ has(target, prop) {
+ if (prop in target)
+ return true;
+ return target.byKey(String(prop)) !== undefined;
+ },
+ ownKeys(target) {
+ return Array.from(target.keys());
+ },
+ getOwnPropertyDescriptor(target, prop) {
+ if (prop in target)
+ return Reflect.getOwnPropertyDescriptor(target, prop);
+ if (typeof prop === "symbol")
+ return;
+ const signal = target.byKey(String(prop));
+ return signal ? {
+ enumerable: true,
+ configurable: true,
+ writable: true,
+ value: signal
+ } : undefined;
+ }
+ });
+}
+function isStore(value) {
+ return isSignalOfType(value, TYPE_STORE);
+}
+
+// src/signal.ts
+var SIGNAL_TYPES = new Set([
+ TYPE_STATE,
+ TYPE_MEMO,
+ TYPE_TASK,
+ TYPE_SENSOR,
+ TYPE_SLOT,
+ TYPE_LIST,
+ TYPE_COLLECTION,
+ TYPE_STORE
+]);
+function createComputed(callback, options) {
+ return isAsyncFunction(callback) ? createTask(callback, options) : createMemo(callback, options);
+}
+function createSignal(value) {
+ if (isSignal(value))
+ return value;
+ if (value == null)
+ throw new InvalidSignalValueError("createSignal", value);
+ if (isAsyncFunction(value))
+ return createTask(value);
+ if (isFunction(value))
+ return createMemo(value);
+ if (Array.isArray(value) && value.every((item) => item != null))
+ return createList(value);
+ if (isRecord(value))
+ return createStore(value);
+ return createState(value);
+}
+function createMutableSignal(value) {
+ if (isMutableSignal(value))
+ return value;
+ if (value == null || isFunction(value) || isSignal(value))
+ throw new InvalidSignalValueError("createMutableSignal", value);
+ if (Array.isArray(value) && value.every((item) => item != null))
+ return createList(value);
+ if (isRecord(value))
+ return createStore(value);
+ return createState(value);
+}
+function isComputed(value) {
+ return isMemo(value) || isTask(value);
+}
+function isSignal(value) {
+ return value != null && SIGNAL_TYPES.has(value[Symbol.toStringTag]);
+}
+function isMutableSignal(value) {
+ return isState(value) || isStore(value) || isList(value);
+}
+
+// src/nodes/slot.ts
+var settingSlots = new WeakSet;
+function isSignalOrDescriptor(value) {
+ if (isSignal(value))
+ return true;
+ return value !== null && typeof value === "object" && "get" in value && typeof value.get === "function";
+}
+function createSlot(initialSignal, options) {
+ validateSignalValue(TYPE_SLOT, initialSignal, isSignalOrDescriptor);
+ let delegated = initialSignal;
+ const guard = options?.guard;
+ const node = {
+ fn: () => delegated.get(),
+ value: undefined,
+ flags: FLAG_DIRTY,
+ sources: null,
+ sourcesTail: null,
+ sinks: null,
+ sinksTail: null,
+ equals: options?.equals ?? DEFAULT_EQUALITY,
+ error: undefined
+ };
+ const get = () => {
+ if (activeSink)
+ link(node, activeSink);
+ refresh(node);
+ if (node.error)
+ throw node.error;
+ return node.value;
+ };
+ const set = (next) => {
+ if (settingSlots.has(node))
+ throw new Error("[Slot] Circular delegation detected in set()");
+ settingSlots.add(node);
+ try {
+ if (isSlot(delegated))
+ return void delegated.set(next);
+ if ("set" in delegated && typeof delegated.set === "function") {
+ validateSignalValue(TYPE_SLOT, next, guard);
+ delegated.set(next);
+ } else {
+ throw new ReadonlySignalError(TYPE_SLOT);
+ }
+ } finally {
+ settingSlots.delete(node);
+ }
+ };
+ const replace = (next) => {
+ validateSignalValue(TYPE_SLOT, next, isSignalOrDescriptor);
+ delegated = next;
+ node.flags |= FLAG_DIRTY;
+ for (let e = node.sinks;e; e = e.nextSink)
+ propagate(e.sink);
+ if (batchDepth === 0)
+ flush();
+ };
+ return {
+ [Symbol.toStringTag]: TYPE_SLOT,
+ configurable: true,
+ enumerable: true,
+ get,
+ set,
+ replace,
+ current: () => delegated
+ };
+}
+function isSlot(value) {
+ return isSignalOfType(value, TYPE_SLOT);
+}
+export {
+ valueString,
+ untrack,
+ unown,
+ match,
+ isTask,
+ isStore,
+ isState,
+ isSlot,
+ isSignalOfType,
+ isSignal,
+ isSensor,
+ isRecord,
+ isObjectOfType,
+ isMutableSignal,
+ isMemo,
+ isList,
+ isFunction,
+ isEqual,
+ isComputed,
+ isCollection,
+ isAsyncFunction,
+ createTask,
+ createStore,
+ createState,
+ createSlot,
+ createSignal,
+ createSensor,
+ createScope,
+ createMutableSignal,
+ createMemo,
+ createList,
+ createEffect,
+ createComputed,
+ createCollection,
+ batch,
+ UnsetSignalValueError,
+ SKIP_EQUALITY,
+ RequiredOwnerError,
+ ReadonlySignalError,
+ PromiseValueError,
+ NullishSignalValueError,
+ InvalidStoreMutationError,
+ InvalidSignalValueError,
+ InvalidCallbackError,
+ EffectConvergenceError,
+ DuplicateKeyError,
+ DEFAULT_EQUALITY,
+ DEEP_EQUALITY,
+ CircularDependencyError
+};
diff --git a/index.ts b/index.ts
index 1810384..a445922 100644
--- a/index.ts
+++ b/index.ts
@@ -1,15 +1,17 @@
/**
* @name Cause & Effect
- * @version 1.3.4
+ * @version 1.4.0
* @author Esther Brunner
*/
export {
CircularDependencyError,
DuplicateKeyError,
+ EffectConvergenceError,
type Guard,
InvalidCallbackError,
InvalidSignalValueError,
+ InvalidStoreMutationError,
NullishSignalValueError,
PromiseValueError,
ReadonlySignalError,
diff --git a/package.json b/package.json
index faa1f55..ea91091 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@zeix/cause-effect",
- "version": "1.3.4",
+ "version": "1.4.0",
"repository": {
"type": "git",
"url": "https://github.com/zeixcom/cause-effect"
@@ -9,10 +9,24 @@
"author": "Esther Brunner",
"type": "module",
"main": "index.js",
- "module": "index.ts",
"types": "types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./types/index.d.ts",
+ "bun": "./index.ts",
+ "default": "./index.js"
+ },
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "index.js",
+ "index.ts",
+ "src",
+ "types",
+ "SECURITY.md"
+ ],
"devDependencies": {
- "@biomejs/biome": "2.4.6",
+ "@biomejs/biome": "^2.5.3",
"@types/bun": "^1.3.14",
"@zeix/cause-effect-stable": "npm:@zeix/cause-effect",
"mitata": "^1.0.34",
@@ -22,6 +36,11 @@
"peerDependencies": {
"typescript": ">=5.8.0"
},
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ },
"description": "Cause & Effect - reactive state management primitives library for TypeScript.",
"license": "MIT",
"keywords": [
@@ -34,10 +53,11 @@
"access": "public"
},
"scripts": {
- "build": "bunx tsc --project tsconfig.build.json && bun build index.ts --outdir ./ --minify && bun build index.ts --outfile index.dev.js",
+ "build": "bunx tsc --project tsconfig.build.json && bun build index.ts --outfile index.js",
"bench": "bun run bench/reactivity.bench.ts",
"test": "bun test --path-ignore-patterns=**/regression*",
"regression": "bun test test/regression-performance.test.ts && bun test test/regression-bundle.test.ts",
- "lint": "bunx biome lint --write"
+ "check": "bunx tsc --noEmit && bunx biome check . && bun run test && bun test test/regression-bundle.test.ts",
+ "lint": "bunx biome check --write"
}
}
diff --git a/src/errors.ts b/src/errors.ts
index 76c783c..d67ae29 100644
--- a/src/errors.ts
+++ b/src/errors.ts
@@ -29,6 +29,23 @@ class CircularDependencyError extends Error {
}
}
+/**
+ * Error thrown when queued effects keep re-triggering each other without settling.
+ */
+class EffectConvergenceError extends Error {
+ /**
+ * Constructs a new EffectConvergenceError.
+ *
+ * @param passes - The number of flush passes that ran without the graph settling.
+ */
+ constructor(passes: number) {
+ super(
+ `[Effect] Effects did not settle after ${passes} flush passes — check for effects that write to signals they depend on`,
+ )
+ this.name = 'EffectConvergenceError'
+ }
+}
+
/**
* Error thrown when a signal value is null or undefined.
*/
@@ -146,6 +163,26 @@ class DuplicateKeyError extends Error {
}
}
+/**
+ * Error thrown when a Store property is assigned, deleted, or defined directly via the proxy.
+ */
+class InvalidStoreMutationError extends TypeError {
+ /**
+ * Constructs a new InvalidStoreMutationError.
+ *
+ * @param prop - The property name that was directly mutated.
+ * @param action - The kind of mutation attempted (`'assign to'`, `'delete'`, or `'define'`).
+ */
+ constructor(prop: string, action: 'assign to' | 'delete' | 'define') {
+ const guidance =
+ action === 'delete'
+ ? `use store.remove(${JSON.stringify(prop)})`
+ : `use store.${prop}.set(value), store.set(next), or store.add(key, value)`
+ super(`[Store] Cannot ${action} property "${prop}" directly — ${guidance}`)
+ this.name = 'InvalidStoreMutationError'
+ }
+}
+
/* === Validation Functions === */
function validateSignalValue(
@@ -182,17 +219,19 @@ function validateCallback(
}
export {
- type Guard,
CircularDependencyError,
- NullishSignalValueError,
- InvalidSignalValueError,
- UnsetSignalValueError,
+ DuplicateKeyError,
+ EffectConvergenceError,
+ type Guard,
InvalidCallbackError,
+ InvalidSignalValueError,
+ InvalidStoreMutationError,
+ NullishSignalValueError,
+ PromiseValueError,
ReadonlySignalError,
RequiredOwnerError,
- DuplicateKeyError,
- PromiseValueError,
- validateSignalValue,
- validateReadValue,
+ UnsetSignalValueError,
validateCallback,
+ validateReadValue,
+ validateSignalValue,
}
diff --git a/src/graph.ts b/src/graph.ts
index 9b1c4c1..e2a449e 100644
--- a/src/graph.ts
+++ b/src/graph.ts
@@ -1,4 +1,9 @@
-import { CircularDependencyError, type Guard, PromiseValueError } from './errors'
+import {
+ CircularDependencyError,
+ EffectConvergenceError,
+ type Guard,
+ PromiseValueError,
+} from './errors'
import { isRecord } from './util'
/* === Internal Types === */
@@ -231,12 +236,7 @@ const deepEqualInner = (
): boolean => {
if (Object.is(a, b)) return true
if (typeof a !== typeof b) return false
- if (
- a == null ||
- typeof a !== 'object' ||
- b == null ||
- typeof b !== 'object'
- )
+ if (a == null || typeof a !== 'object' || b == null || typeof b !== 'object')
return false
// Cycle guard: if `a` is already on the current recursion path, treat this
@@ -401,14 +401,13 @@ function propagate(node: SinkNode, newFlag = FLAG_DIRTY): void {
}
// Propagate Check to sinks
- for (let e = node.sinks; e; e = e.nextSink)
- propagate(e.sink, FLAG_CHECK)
+ for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink, FLAG_CHECK)
} else {
if ((flags & (FLAG_DIRTY | FLAG_CHECK)) >= newFlag) return
// Enqueue effect for later execution
const wasQueued = flags & (FLAG_DIRTY | FLAG_CHECK)
- node.flags = newFlag
+ node.flags = (flags & FLAG_RUNNING) | newFlag
if (!wasQueued) queuedEffects.push(node as EffectNode)
}
}
@@ -515,8 +514,7 @@ function recomputeTask(node: TaskNode): void {
if (node.error || !node.equals(next, node.value)) {
node.value = next
node.error = undefined
- for (let e = node.sinks; e; e = e.nextSink)
- propagate(e.sink)
+ for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
}
setState(node.pendingNode, false)
})
@@ -534,8 +532,7 @@ function recomputeTask(node: TaskNode): void {
) {
// We don't clear old value on errors
node.error = error
- for (let e = node.sinks; e; e = e.nextSink)
- propagate(e.sink)
+ for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
}
setState(node.pendingNode, false)
})
@@ -560,9 +557,9 @@ function runEffect(node: EffectNode): void {
activeSink = prevContext
activeOwner = prevOwner
trimSources(node)
+ // Keep a re-mark from the effect's own writes so it converges
+ node.flags &= FLAG_DIRTY | FLAG_CHECK
}
-
- node.flags = FLAG_CLEAN
}
function refresh(node: SinkNode): void {
@@ -575,11 +572,7 @@ function refresh(node: SinkNode): void {
if (node.flags & FLAG_RUNNING) {
throw new CircularDependencyError(
- 'controller' in node
- ? TYPE_TASK
- : 'value' in node
- ? TYPE_MEMO
- : 'Effect',
+ 'controller' in node ? TYPE_TASK : 'value' in node ? TYPE_MEMO : 'Effect',
)
}
@@ -594,19 +587,56 @@ function refresh(node: SinkNode): void {
/* === Batching === */
+const MAX_FLUSH_PASSES = 1000
+
function flush(): void {
if (flushing) return
flushing = true
+ let errors: unknown[] | undefined
+ let passes = 0
try {
- for (let i = 0; i < queuedEffects.length; i++) {
- // biome-ignore lint/style/noNonNullAssertion: index is always within bounds of a populated EffectNode[]
- const effect = queuedEffects[i]!
- if (effect.flags & (FLAG_DIRTY | FLAG_CHECK)) refresh(effect)
+ while (queuedEffects.length > 0) {
+ if (++passes > MAX_FLUSH_PASSES) {
+ queuedEffects.length = 0
+ if (!errors) errors = []
+ errors.push(new EffectConvergenceError(MAX_FLUSH_PASSES))
+ break
+ }
+ const batch = queuedEffects.slice()
+ queuedEffects.length = 0
+ for (let i = 0; i < batch.length; i++) {
+ // biome-ignore lint/style/noNonNullAssertion: index is always within bounds of a populated EffectNode[]
+ const effect = batch[i]!
+ // A running effect is converged by its own runner after its fn returns
+ if (effect.flags & FLAG_RUNNING) continue
+ if (effect.flags & (FLAG_DIRTY | FLAG_CHECK)) {
+ try {
+ refresh(effect)
+ } catch (err) {
+ if (!errors) errors = []
+ errors.push(err)
+ }
+ }
+ }
}
- queuedEffects.length = 0
} finally {
flushing = false
}
+ if (errors) {
+ if (errors.length === 1) throw errors[0]
+ throw new AggregateError(errors, 'Multiple effects threw during flush')
+ }
+}
+
+/**
+ * Enqueues an effect that is still dirty after running (it wrote to its own
+ * dependencies) and, outside a batch, flushes until the graph converges.
+ */
+function scheduleEffect(node: EffectNode): void {
+ if (node.flags & (FLAG_DIRTY | FLAG_CHECK)) {
+ queuedEffects.push(node)
+ if (batchDepth === 0) flush()
+ }
}
/**
@@ -763,52 +793,53 @@ function makeSubscribe(node: SourceNode, onWatch?: () => Cleanup): () => void {
}
export {
- type Cleanup,
- type ComputedOptions,
- type EffectCallback,
- type EffectNode,
- type MaybeCleanup,
- type MemoCallback,
- type MemoNode,
- type Scope,
- type ScopeOptions,
- type Signal,
- type SignalOptions,
- type SinkNode,
- type StateNode,
- type TaskCallback,
- type TaskNode,
activeOwner,
activeSink,
batch,
batchDepth,
+ type Cleanup,
+ type ComputedOptions,
createScope,
- DEFAULT_EQUALITY,
DEEP_EQUALITY,
- isEqual,
- SKIP_EQUALITY,
+ DEFAULT_EQUALITY,
+ type EffectCallback,
+ type EffectNode,
FLAG_CHECK,
FLAG_CLEAN,
FLAG_DIRTY,
FLAG_RELINK,
flush,
+ isEqual,
link,
+ type MaybeCleanup,
+ type MemoCallback,
+ type MemoNode,
makeSubscribe,
propagate,
refresh,
registerCleanup,
runCleanup,
runEffect,
+ type Scope,
+ type ScopeOptions,
+ type Signal,
+ type SignalOptions,
+ type SinkNode,
+ SKIP_EQUALITY,
+ type StateNode,
+ scheduleEffect,
setState,
- trimSources,
+ type TaskCallback,
+ type TaskNode,
TYPE_COLLECTION,
TYPE_LIST,
TYPE_MEMO,
TYPE_SENSOR,
- TYPE_STATE,
TYPE_SLOT,
+ TYPE_STATE,
TYPE_STORE,
TYPE_TASK,
+ trimSources,
unlink,
unown,
untrack,
diff --git a/src/nodes/collection.ts b/src/nodes/collection.ts
index 5b2b9b2..5610c1a 100644
--- a/src/nodes/collection.ts
+++ b/src/nodes/collection.ts
@@ -61,9 +61,7 @@ type Collection = Signal> = {
byKey(key: string): S | undefined
keyAt(index: number): string | undefined
indexOfKey(key: string): number
- deriveCollection(
- callback: (sourceValue: T) => R,
- ): Collection
+ deriveCollection(callback: (sourceValue: T) => R): Collection
deriveCollection(
callback: (sourceValue: T, abort: AbortSignal) => Promise,
): Collection
@@ -154,10 +152,7 @@ function deriveCollection(
const sourceValue = itemSignal.get() as U
if (sourceValue == null) return prev as T
return (
- callback as (
- sourceValue: U,
- abort: AbortSignal,
- ) => Promise
+ callback as (sourceValue: U, abort: AbortSignal) => Promise
)(sourceValue, abort)
})
: createMemo(() => {
@@ -449,8 +444,10 @@ function createCollection = Signal>(
if (!key) continue
const signal = signals.get(key)
if (signal && isState(signal)) {
- // Update reverse map: remove old reference, add new
- itemToKey.delete(signal.get())
+ // Update reverse map: remove old reference, add new.
+ // untrack prevents the read from leaking an edge into
+ // the caller's effect when applyChanges is called inside one.
+ itemToKey.delete(untrack(() => signal.get()))
signal.set(item)
itemToKey.set(item, key)
}
@@ -576,13 +573,13 @@ function isCollection = Signal>(
/* === Exports === */
export {
- createCollection,
- deriveCollection,
- isCollection,
type Collection,
type CollectionCallback,
type CollectionChanges,
type CollectionOptions,
type CollectionSource,
+ createCollection,
type DeriveCollectionCallback,
+ deriveCollection,
+ isCollection,
}
diff --git a/src/nodes/effect.ts b/src/nodes/effect.ts
index 0150d6b..01e9a7a 100644
--- a/src/nodes/effect.ts
+++ b/src/nodes/effect.ts
@@ -15,6 +15,7 @@ import {
runCleanup,
runEffect,
type Signal,
+ scheduleEffect,
trimSources,
} from '../graph'
import { isTask } from './task'
@@ -31,9 +32,11 @@ type MaybePromise = T | Promise
*/
type MatchHandlers[]> = {
/** Called when all signals have a value. Receives a tuple of resolved values. */
- ok: (values: {
- [K in keyof T]: T[K] extends Signal ? V : never
- }) => MaybePromise
+ ok: (
+ values: {
+ [K in keyof T]: T[K] extends Signal ? V : never
+ },
+ ) => MaybePromise
/** Called when one or more signals hold an error. Defaults to `console.error`. */
err?: (errors: readonly Error[]) => MaybePromise
/** Called when one or more signals are unset (pending). */
@@ -65,9 +68,16 @@ type SingleMatchHandlers = {
* Effects run immediately upon creation and re-run when any tracked signal changes.
* Effects are executed during the flush phase, after all updates have been batched.
*
+ * An effect that writes to a signal it also depends on re-runs until the graph
+ * settles, so its last run always observes the final signal values. Effect graphs
+ * that never settle (e.g. an unconditional self-increment, or two effects writing
+ * each other's dependencies) throw `EffectConvergenceError` after a bounded number
+ * of flush passes instead of looping forever.
+ *
* @since 0.1.0
* @param fn - The effect function that can track dependencies and register cleanup callbacks
* @returns A cleanup function that can be called to dispose of the effect
+ * @throws EffectConvergenceError If effects keep re-triggering each other without settling
*
* @example
* ```ts
@@ -111,6 +121,7 @@ function createEffect(fn: EffectCallback): Cleanup {
if (activeOwner) registerCleanup(activeOwner, dispose)
runEffect(node)
+ scheduleEffect(node)
return dispose
}
@@ -212,19 +223,21 @@ function match(
const owner = activeOwner
const controller = new AbortController()
registerCleanup(owner, () => controller.abort())
- out.then(cleanup => {
- if (!controller.signal.aborted && typeof cleanup === 'function')
- registerCleanup(owner, cleanup)
- }).catch(e => {
- err([e instanceof Error ? e : new Error(String(e))])
- })
+ out
+ .then(cleanup => {
+ if (!controller.signal.aborted && typeof cleanup === 'function')
+ registerCleanup(owner, cleanup)
+ })
+ .catch(e => {
+ err([e instanceof Error ? e : new Error(String(e))])
+ })
}
}
export {
- type MaybePromise,
- type MatchHandlers,
- type SingleMatchHandlers,
createEffect,
+ type MatchHandlers,
+ type MaybePromise,
match,
+ type SingleMatchHandlers,
}
diff --git a/src/nodes/list.ts b/src/nodes/list.ts
index 808cfdc..29618bf 100644
--- a/src/nodes/list.ts
+++ b/src/nodes/list.ts
@@ -99,9 +99,7 @@ type List = MutableSignal> = {
replace(key: string, value: T): void
sort(compareFn?: (a: T, b: T) => number): void
splice(start: number, deleteCount?: number, ...items: T[]): T[]
- deriveCollection(
- callback: (sourceValue: T) => R,
- ): Collection
+ deriveCollection(callback: (sourceValue: T) => R): Collection
deriveCollection(
callback: (sourceValue: T, abort: AbortSignal) => Promise,
): Collection
@@ -281,9 +279,7 @@ function createList<
const [generateKey, contentBased] = getKeyGenerator(options?.keyConfig)
const itemEquals = options?.itemEquals ?? DEEP_EQUALITY
const itemFactory = (options?.createItem ??
- ((item: T) => createState(item, { equals: itemEquals }))) as (
- value: T,
- ) => S
+ ((item: T) => createState(item, { equals: itemEquals }))) as (value: T) => S
// --- Internal helpers ---
@@ -334,10 +330,7 @@ function createList<
batch(() => {
for (const key in changes.change) {
const val = changes.change[key]
- validateSignalValue(
- `${TYPE_LIST} item for key "${key}"`,
- val,
- )
+ validateSignalValue(`${TYPE_LIST} item for key "${key}"`, val)
const signal = signals.get(key)
if (signal) signal.set(val as T)
}
@@ -362,8 +355,7 @@ function createList<
// --- Initialize ---
for (let i = 0; i < value.length; i++) {
const val = value[i]
- if (val == null)
- throw new NullishSignalValueError(`${TYPE_LIST} item ${i}`)
+ if (val == null) throw new NullishSignalValueError(`${TYPE_LIST} item ${i}`)
let key = keys[i]
if (!key) {
key = generateKey(val)
@@ -422,7 +414,10 @@ function createList<
},
set(next: T[]) {
- const prev = node.flags & FLAG_DIRTY ? buildValue() : node.value
+ // Use cached value if clean, recompute if dirty. untrack prevents
+ // buildValue's child .get() calls from leaking edges into whatever
+ // effect is currently active (which would cause over-broad re-runs).
+ const prev = node.flags & FLAG_DIRTY ? untrack(buildValue) : node.value
const changes = diffArrays(
prev,
next,
@@ -441,7 +436,7 @@ function createList<
},
update(fn: (prev: T[]) => T[]) {
- list.set(fn(list.get()))
+ list.set(fn(untrack(() => list.get())))
},
at(index: number) {
@@ -472,8 +467,7 @@ function createList<
add(value: T) {
const key = generateKey(value)
- if (signals.has(key))
- throw new DuplicateKeyError(TYPE_LIST, key, value)
+ if (signals.has(key)) throw new DuplicateKeyError(TYPE_LIST, key, value)
keys.push(key)
validateSignalValue(`${TYPE_LIST} item for key "${key}"`, value)
signals.set(key, itemFactory(value))
@@ -484,15 +478,12 @@ function createList<
},
remove(keyOrIndex: string | number) {
- const key =
- typeof keyOrIndex === 'number' ? keys[keyOrIndex] : keyOrIndex
+ const key = typeof keyOrIndex === 'number' ? keys[keyOrIndex] : keyOrIndex
if (key === undefined) return
const ok = signals.delete(key)
if (ok) {
const index =
- typeof keyOrIndex === 'number'
- ? keyOrIndex
- : keys.indexOf(key)
+ typeof keyOrIndex === 'number' ? keyOrIndex : keys.indexOf(key)
if (index >= 0) keys.splice(index, 1)
node.flags |= FLAG_DIRTY | FLAG_RELINK
for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
@@ -525,10 +516,12 @@ function createList<
sort(compareFn?: (a: T, b: T) => number) {
const entries: [string, T][] = []
- for (const key of keys) {
- const v = signals.get(key)?.get()
- if (v !== undefined) entries.push([key, v])
- }
+ untrack(() => {
+ for (const key of keys) {
+ const v = signals.get(key)?.get()
+ if (v !== undefined) entries.push([key, v])
+ }
+ })
entries.sort(
isFunction(compareFn)
? (a, b) => compareFn(a[1], b[1])
@@ -548,14 +541,11 @@ function createList<
splice(start: number, deleteCount?: number, ...items: T[]) {
const length = keys.length
const actualStart =
- start < 0
- ? Math.max(0, length + start)
- : Math.min(start, length)
+ start < 0 ? Math.max(0, length + start) : Math.min(start, length)
const actualDeleteCount = Math.max(
0,
Math.min(
- deleteCount ??
- Math.max(0, length - Math.max(0, actualStart)),
+ deleteCount ?? Math.max(0, length - Math.max(0, actualStart)),
length - actualStart,
),
)
@@ -564,18 +554,21 @@ function createList<
const remove = {} as Record
let hasRemove = false
- // Collect items to delete
- for (let i = 0; i < actualDeleteCount; i++) {
- const index = actualStart + i
- const key = keys[index]
- if (key) {
- const signal = signals.get(key)
- if (signal) {
- remove[key] = signal.get()
- hasRemove = true
+ // Collect items to delete — untrack the reads so the caller's
+ // effect does not gain edges to the deleted item signals.
+ untrack(() => {
+ for (let i = 0; i < actualDeleteCount; i++) {
+ const index = actualStart + i
+ const key = keys[index]
+ if (key) {
+ const signal = signals.get(key)
+ if (signal) {
+ remove[key] = signal.get()
+ hasRemove = true
+ }
}
}
- }
+ })
// Build new key order
const newOrder = keys.slice(0, actualStart)
@@ -650,14 +643,14 @@ function isList = MutableSignal>(
/* === Exports === */
export {
+ createList,
type DiffResult,
+ getKeyGenerator,
+ isList,
type KeyConfig,
+ keysEqual,
type List,
type ListOptions,
- type UnknownRecord,
- createList,
- isList,
- getKeyGenerator,
- keysEqual,
TYPE_LIST,
+ type UnknownRecord,
}
diff --git a/src/nodes/slot.ts b/src/nodes/slot.ts
index 9df9ae5..8709c37 100644
--- a/src/nodes/slot.ts
+++ b/src/nodes/slot.ts
@@ -147,9 +147,7 @@ function createSlot(
}
}
- const replace = (
- next: Signal | SlotDescriptor,
- ): void => {
+ const replace = (next: Signal | SlotDescriptor): void => {
validateSignalValue(TYPE_SLOT, next, isSignalOrDescriptor)
delegated = next
diff --git a/src/nodes/store.ts b/src/nodes/store.ts
index 3df1a2b..548a4ea 100644
--- a/src/nodes/store.ts
+++ b/src/nodes/store.ts
@@ -1,4 +1,8 @@
-import { DuplicateKeyError, validateSignalValue } from '../errors'
+import {
+ DuplicateKeyError,
+ InvalidStoreMutationError,
+ validateSignalValue,
+} from '../errors'
import {
batch,
batchDepth,
@@ -40,10 +44,7 @@ type BaseStore = {
readonly [Symbol.toStringTag]: 'Store'
readonly [Symbol.isConcatSpreadable]: false
[Symbol.iterator](): IterableIterator<
- [
- string,
- State | Store | List,
- ]
+ [string, State | Store | List]
>
keys(): IterableIterator
byKey(
@@ -95,10 +96,7 @@ function diffRecords(prev: T, next: T): DiffResult {
for (const key of nextKeys) {
if (key in prev) {
if (
- !DEEP_EQUALITY(
- prev[key] as unknown & {},
- next[key] as unknown & {},
- )
+ !DEEP_EQUALITY(prev[key] as unknown & {}, next[key] as unknown & {})
) {
change[key] = next[key]
changed = true
@@ -136,6 +134,18 @@ function diffRecords(prev: T, next: T): DiffResult {
* user.name.set('Bob'); // Only name subscribers react
* console.log(user.get()); // { name: 'Bob', age: 30 }
* ```
+ *
+ * Direct property assignment, deletion, or `Object.defineProperty` through the
+ * proxy throws `InvalidStoreMutationError` — use `store.key.set(value)`,
+ * `store.set(next)`, `store.add(key, value)`, or `store.remove(key)` instead.
+ * Properties are typed as signals (not raw values) so destructuring preserves
+ * reactivity; this means proxy assignment is a compile-time error for typed
+ * stores. The runtime guard extends that protection to `any`-typed access,
+ * JS consumers, and `Object.assign`. See ADR-0017 for the full rationale.
+ *
+ * Note: a data key named like a base method (`get`, `set`, `keys`, `update`,
+ * `add`, `remove`, `byKey`) shadows the method via proxy access. Use
+ * `store.byKey(key)` to reach such a property.
*/
function createStore(
value: T,
@@ -258,11 +268,7 @@ function createStore(
for (const [key, signal] of signals) {
yield [key, signal] as [
string,
- (
- | State
- | Store
- | List
- ),
+ State | Store | List,
]
}
},
@@ -273,8 +279,7 @@ function createStore(
},
byKey(key: K) {
- return signals.get(key) as T[K] extends readonly (infer U extends
- {})[]
+ return signals.get(key) as T[K] extends readonly (infer U extends {})[]
? List
: T[K] extends UnknownRecord
? Store
@@ -315,8 +320,7 @@ function createStore(
// Use cached value if clean, recompute if dirty. untrack prevents
// buildValue's child .get() calls from leaking edges into whatever
// effect is currently active (which would cause over-broad re-runs).
- const prev =
- node.flags & FLAG_DIRTY ? untrack(buildValue) : node.value
+ const prev = node.flags & FLAG_DIRTY ? untrack(buildValue) : node.value
const changes = diffRecords(prev, next)
if (applyChanges(changes)) {
@@ -327,12 +331,11 @@ function createStore(
},
update(fn: (prev: T) => T) {
- store.set(fn(store.get()))
+ store.set(fn(untrack(() => store.get())))
},
add(key: K, value: T[K]) {
- if (signals.has(key))
- throw new DuplicateKeyError(TYPE_STORE, key, value)
+ if (signals.has(key)) throw new DuplicateKeyError(TYPE_STORE, key, value)
addSignal(key, value)
node.flags |= FLAG_DIRTY | FLAG_RELINK
for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink)
@@ -357,6 +360,15 @@ function createStore(
if (typeof prop !== 'symbol')
return target.byKey(prop as keyof T & string)
},
+ set(_target, prop) {
+ throw new InvalidStoreMutationError(String(prop), 'assign to')
+ },
+ deleteProperty(_target, prop) {
+ throw new InvalidStoreMutationError(String(prop), 'delete')
+ },
+ defineProperty(_target, prop) {
+ throw new InvalidStoreMutationError(String(prop), 'define')
+ },
has(target, prop) {
if (prop in target) return true
return target.byKey(String(prop) as keyof T & string) !== undefined
@@ -365,8 +377,7 @@ function createStore(
return Array.from(target.keys())
},
getOwnPropertyDescriptor(target, prop) {
- if (prop in target)
- return Reflect.getOwnPropertyDescriptor(target, prop)
+ if (prop in target) return Reflect.getOwnPropertyDescriptor(target, prop)
if (typeof prop === 'symbol') return undefined
const signal = target.byKey(String(prop) as keyof T & string)
return signal
diff --git a/src/signal.ts b/src/signal.ts
index 0d7a37e..485d44e 100644
--- a/src/signal.ts
+++ b/src/signal.ts
@@ -89,8 +89,7 @@ function createSignal(value: unknown): unknown {
if (value == null) throw new InvalidSignalValueError('createSignal', value)
if (isAsyncFunction(value))
return createTask(value as TaskCallback)
- if (isFunction(value))
- return createMemo(value as MemoCallback)
+ if (isFunction(value)) return createMemo(value as MemoCallback)
if (Array.isArray(value) && value.every(item => item != null))
return createList(value as (unknown & {})[])
if (isRecord(value)) return createStore(value)
@@ -159,11 +158,11 @@ function isMutableSignal(value: unknown): value is MutableSignal {
}
export {
- type MutableSignal,
createComputed,
- createSignal,
createMutableSignal,
+ createSignal,
isComputed,
- isSignal,
isMutableSignal,
+ isSignal,
+ type MutableSignal,
}
diff --git a/src/util.ts b/src/util.ts
index ac7df23..af0d146 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -66,11 +66,11 @@ function valueString(value: unknown): string {
/* === Exports === */
export {
- isFunction,
isAsyncFunction,
- isSyncFunction,
+ isFunction,
isObjectOfType,
- isSignalOfType,
isRecord,
+ isSignalOfType,
+ isSyncFunction,
valueString,
}
diff --git a/test/benchmark.test.ts b/test/benchmark.test.ts
index 5a16f39..582d6f5 100644
--- a/test/benchmark.test.ts
+++ b/test/benchmark.test.ts
@@ -291,9 +291,7 @@ for (const framework of [v18]) {
})
test('mux', async () => {
- const heads = new Array(100)
- .fill(null)
- .map(_ => framework.signal(0))
+ const heads = new Array(100).fill(null).map(_ => framework.signal(0))
const mux = framework.computed(() =>
Object.fromEntries(heads.map(h => h.read()).entries()),
)
@@ -451,9 +449,7 @@ for (const framework of [v18]) {
framework.withBuild(() => {
const A = framework.signal(0)
const B = framework.signal(0)
- const C = framework.computed(
- () => (A.read() % 2) + (B.read() % 2),
- )
+ const C = framework.computed(() => (A.read() % 2) + (B.read() % 2))
const D = framework.computed(() =>
numbers.map(i => ({
x: i + (A.read() % 2) - (B.read() % 2),
@@ -547,12 +543,8 @@ for (const framework of [v18]) {
const m: CellxLayer = layer
const s = {
prop1: framework.computed(() => m.prop2.read()),
- prop2: framework.computed(
- () => m.prop1.read() - m.prop3.read(),
- ),
- prop3: framework.computed(
- () => m.prop2.read() + m.prop4.read(),
- ),
+ prop2: framework.computed(() => m.prop1.read() - m.prop3.read()),
+ prop3: framework.computed(() => m.prop2.read() + m.prop4.read()),
prop4: framework.computed(() => m.prop3.read()),
}
diff --git a/test/collection.test.ts b/test/collection.test.ts
index 1084775..7101eb3 100644
--- a/test/collection.test.ts
+++ b/test/collection.test.ts
@@ -113,9 +113,9 @@ describe('Collection', () => {
expect(isCollection(42)).toBe(false)
expect(isCollection(null)).toBe(false)
expect(isCollection({})).toBe(false)
- expect(
- isList(createCollection(() => () => {}, { value: [1] })),
- ).toBe(false)
+ expect(isList(createCollection(() => () => {}, { value: [1] }))).toBe(
+ false,
+ )
})
})
@@ -175,9 +175,7 @@ describe('Collection', () => {
describe('applyChanges', () => {
test('should add items', () => {
- let apply:
- | ((changes: CollectionChanges) => void)
- | undefined
+ let apply: ((changes: CollectionChanges) => void) | undefined
const col = createCollection(applyChanges => {
apply = applyChanges
return () => {}
@@ -204,9 +202,7 @@ describe('Collection', () => {
test('should change item values', () => {
let apply:
- | ((
- changes: CollectionChanges<{ id: string; val: number }>,
- ) => void)
+ | ((changes: CollectionChanges<{ id: string; val: number }>) => void)
| undefined
const col = createCollection(
applyChanges => {
@@ -239,9 +235,7 @@ describe('Collection', () => {
test('should remove items', () => {
let apply:
- | ((
- changes: CollectionChanges<{ id: string; v: number }>,
- ) => void)
+ | ((changes: CollectionChanges<{ id: string; v: number }>) => void)
| undefined
const col = createCollection(
applyChanges => {
@@ -286,9 +280,7 @@ describe('Collection', () => {
test('should handle mixed add/change/remove', () => {
let apply:
- | ((
- changes: CollectionChanges<{ id: string; v: number }>,
- ) => void)
+ | ((changes: CollectionChanges<{ id: string; v: number }>) => void)
| undefined
const col = createCollection(
applyChanges => {
@@ -328,9 +320,7 @@ describe('Collection', () => {
})
test('should skip when no changes provided', () => {
- let apply:
- | ((changes: CollectionChanges) => void)
- | undefined
+ let apply: ((changes: CollectionChanges) => void) | undefined
const col = createCollection(
applyChanges => {
apply = applyChanges
@@ -358,9 +348,7 @@ describe('Collection', () => {
})
test('should trigger effects on structural changes', () => {
- let apply:
- | ((changes: CollectionChanges) => void)
- | undefined
+ let apply: ((changes: CollectionChanges) => void) | undefined
const col = createCollection(applyChanges => {
apply = applyChanges
return () => {}
@@ -386,9 +374,7 @@ describe('Collection', () => {
})
test('should batch multiple calls', () => {
- let apply:
- | ((changes: CollectionChanges) => void)
- | undefined
+ let apply: ((changes: CollectionChanges) => void) | undefined
const col = createCollection(applyChanges => {
apply = applyChanges
return () => {}
@@ -614,6 +600,48 @@ describe('Collection', () => {
})
})
+ describe('onChanges change-branch tracking leak', () => {
+ // The change branch of onChanges reads signal.get() to update the
+ // itemToKey reverse map. That read must be untracked — otherwise,
+ // when applyChanges is called inside an effect, it leaks an
+ // item->effect edge and the subsequent propagate re-runs the effect
+ // once during setup (transient leak, mode b).
+ test('applyChanges({ change }) inside an effect does not transiently re-run', () => {
+ type Item = { id: string; v: number }
+ let apply: ((changes: CollectionChanges- ) => void) | undefined
+ const col = createCollection
- (
+ applyChanges => {
+ apply = applyChanges
+ return () => {}
+ },
+ { keyConfig: item => item.id, value: [{ id: 'a', v: 1 }] },
+ )
+
+ // Subscribe to activate the collection (triggers watched -> sets apply)
+ const dispose = createScope(() => {
+ createEffect(() => {
+ col.get()
+ })
+ })
+
+ const trigger = createState(0)
+ let runs = 0
+ createEffect((): undefined => {
+ trigger.get()
+ runs++
+ if (runs === 1) {
+ // biome-ignore lint/style/noNonNullAssertion: activated above
+ apply!({ change: [{ id: 'a', v: 2 }] })
+ }
+ })
+
+ // Without the fix, runs is 2 here (transient re-run).
+ expect(runs).toBe(1)
+ expect(col.byKey('a')?.get()).toEqual({ id: 'a', v: 2 })
+ dispose()
+ })
+ })
+
describe('Input Validation', () => {
test('should throw InvalidCallbackError for non-function watched', () => {
expect(() => {
@@ -986,9 +1014,6 @@ test('Type Inference for custom createItem', () => {
? true
: false
type _Test = Expect<
- Equal<
- typeof byKey,
- ReturnType> | undefined
- >
+ Equal> | undefined>
>
})
diff --git a/test/effect.test.ts b/test/effect.test.ts
index a31634f..f915a45 100644
--- a/test/effect.test.ts
+++ b/test/effect.test.ts
@@ -6,6 +6,7 @@ import {
createScope,
createState,
createTask,
+ EffectConvergenceError,
match,
RequiredOwnerError,
} from '../index.ts'
@@ -344,6 +345,190 @@ describe('createEffect', () => {
})
})
+ describe('Error Containment', () => {
+ test('should run sibling effects even when an earlier effect throws', () => {
+ const source = createState(0)
+ let goodRuns = 0
+ createEffect(() => {
+ if (source.get() > 0) throw new Error('boom')
+ })
+ createEffect(() => {
+ source.get()
+ goodRuns++
+ })
+ expect(goodRuns).toBe(1)
+ expect(() => source.set(1)).toThrow('boom')
+ expect(goodRuns).toBe(2)
+ })
+
+ test('should aggregate multiple effect errors and still run non-throwing siblings', () => {
+ const source = createState(0)
+ let goodRuns = 0
+ createEffect(() => {
+ if (source.get() > 0) throw new Error('first')
+ })
+ createEffect(() => {
+ if (source.get() > 0) throw new Error('second')
+ })
+ createEffect(() => {
+ source.get()
+ goodRuns++
+ })
+ let caught: unknown
+ try {
+ source.set(1)
+ } catch (err) {
+ caught = err
+ }
+ expect(caught).toBeInstanceOf(AggregateError)
+ expect((caught as AggregateError).errors.length).toBe(2)
+ expect(((caught as AggregateError).errors[0] as Error).message).toBe(
+ 'first',
+ )
+ expect(((caught as AggregateError).errors[1] as Error).message).toBe(
+ 'second',
+ )
+ expect(goodRuns).toBe(2)
+ })
+
+ test('should re-run a previously throwing effect without CircularDependencyError', () => {
+ const source = createState(0)
+ let lastSeen = 0
+ createEffect(() => {
+ const value = source.get()
+ if (value === 1) throw new Error('boom')
+ lastSeen = value
+ })
+ expect(() => source.set(1)).toThrow('boom')
+
+ let caught: unknown
+ try {
+ source.set(2)
+ } catch (err) {
+ caught = err
+ }
+ expect(caught).toBeUndefined()
+ expect(lastSeen).toBe(2)
+ })
+
+ test('should contain effect errors thrown during batch', () => {
+ const source = createState(0)
+ let goodRuns = 0
+ createEffect(() => {
+ if (source.get() > 0) throw new Error('boom')
+ })
+ createEffect(() => {
+ source.get()
+ goodRuns++
+ })
+ expect(() => {
+ batch(() => {
+ source.set(1)
+ })
+ }).toThrow('boom')
+ expect(goodRuns).toBe(2)
+ })
+ })
+
+ describe('Convergence', () => {
+ test('should re-run a clamping effect so it observes the final value', () => {
+ const source = createState(0)
+ const seen: number[] = []
+ createEffect(() => {
+ const v = source.get()
+ seen.push(v)
+ if (v > 10) source.set(10)
+ })
+ source.set(15)
+ expect(source.get()).toBe(10)
+ expect(seen).toEqual([0, 15, 10])
+ })
+
+ test('should converge a clamping effect inside batch', () => {
+ const source = createState(0)
+ const seen: number[] = []
+ createEffect(() => {
+ const v = source.get()
+ seen.push(v)
+ if (v > 10) source.set(10)
+ })
+ batch(() => {
+ source.set(20)
+ })
+ expect(source.get()).toBe(10)
+ expect(seen).toEqual([0, 20, 10])
+ })
+
+ test('should throw EffectConvergenceError for an unconditional self-write via set()', () => {
+ const source = createState(0)
+ createEffect(() => {
+ if (source.get() > 0) source.set(source.get() + 1)
+ })
+ let caught: unknown
+ try {
+ source.set(1)
+ } catch (err) {
+ caught = err
+ }
+ expect(caught).toBeInstanceOf(EffectConvergenceError)
+ expect((caught as Error).message).toContain('did not settle')
+ })
+
+ test('should throw EffectConvergenceError for a runaway effect at creation', () => {
+ const source = createState(0)
+ expect(() =>
+ createEffect(() => {
+ source.set(source.get() + 1)
+ }),
+ ).toThrow(EffectConvergenceError)
+ })
+
+ test('should throw EffectConvergenceError for mutual effect writes', () => {
+ const a = createState(0)
+ const b = createState(0)
+ expect(() => {
+ createEffect(() => {
+ b.set(a.get() + 1)
+ })
+ createEffect(() => {
+ a.set(b.get() + 1)
+ })
+ }).toThrow(EffectConvergenceError)
+ })
+
+ test('should still run healthy siblings when another effect fails to converge', () => {
+ const source = createState(0)
+ let siblingRuns = 0
+ createEffect(() => {
+ if (source.get() > 0) source.set(source.get() + 1)
+ })
+ createEffect(() => {
+ source.get()
+ siblingRuns++
+ })
+ expect(siblingRuns).toBe(1)
+ expect(() => source.set(1)).toThrow(EffectConvergenceError)
+ expect(siblingRuns).toBeGreaterThan(1)
+ })
+
+ test('should recover after a convergence error', () => {
+ const source = createState(0)
+ const seen: number[] = []
+ createEffect(() => {
+ seen.push(source.get())
+ })
+ const runaway = createState(0)
+ createEffect(() => {
+ if (runaway.get() > 0) runaway.set(runaway.get() + 1)
+ })
+ expect(() => runaway.set(1)).toThrow(EffectConvergenceError)
+
+ // The queue was cleared; unrelated signals keep working normally
+ source.set(5)
+ expect(seen).toEqual([0, 5])
+ })
+ })
+
describe('Input Validation', () => {
test('should throw InvalidCallbackError for non-function', () => {
// @ts-expect-error - Testing invalid input
@@ -351,9 +536,7 @@ describe('createEffect', () => {
'[Effect] Callback null is invalid',
)
// @ts-expect-error - Testing invalid input
- expect(() => createEffect(42)).toThrow(
- '[Effect] Callback 42 is invalid',
- )
+ expect(() => createEffect(42)).toThrow('[Effect] Callback 42 is invalid')
})
})
})
@@ -1092,10 +1275,7 @@ describe('match', () => {
ok: () =>
new Promise((_resolve, reject) => {
// Reject asynchronously, after disposal.
- setTimeout(
- () => reject(new Error('late reject')),
- 10,
- )
+ setTimeout(() => reject(new Error('late reject')), 10)
}),
err: e => {
errCount++
diff --git a/test/equality.test.ts b/test/equality.test.ts
index bf9543f..ac57b21 100644
--- a/test/equality.test.ts
+++ b/test/equality.test.ts
@@ -131,9 +131,9 @@ describe('DEEP_EQUALITY', () => {
})
test('differ in length', () => {
- expect(
- DEEP_EQUALITY([1, 2] as number[], [1, 2, 3] as number[]),
- ).toBe(false)
+ expect(DEEP_EQUALITY([1, 2] as number[], [1, 2, 3] as number[])).toBe(
+ false,
+ )
})
test('differ in one element', () => {
@@ -149,15 +149,15 @@ describe('DEEP_EQUALITY', () => {
})
test('array of equal objects', () => {
- expect(
- DEEP_EQUALITY([{ x: 1 }, { x: 2 }], [{ x: 1 }, { x: 2 }]),
- ).toBe(true)
+ expect(DEEP_EQUALITY([{ x: 1 }, { x: 2 }], [{ x: 1 }, { x: 2 }])).toBe(
+ true,
+ )
})
test('array of objects differing in one element', () => {
- expect(
- DEEP_EQUALITY([{ x: 1 }, { x: 2 }], [{ x: 1 }, { x: 9 }]),
- ).toBe(false)
+ expect(DEEP_EQUALITY([{ x: 1 }, { x: 2 }], [{ x: 1 }, { x: 9 }])).toBe(
+ false,
+ )
})
})
diff --git a/test/list.test.ts b/test/list.test.ts
index d5a5ce3..b5627a8 100644
--- a/test/list.test.ts
+++ b/test/list.test.ts
@@ -199,9 +199,7 @@ describe('List', () => {
const list = createList([{ id: 'a', val: 1 }], {
keyConfig: item => item.id,
})
- expect(() => list.add({ id: 'a', val: 2 })).toThrow(
- 'already exists',
- )
+ expect(() => list.add({ id: 'a', val: 2 })).toThrow('already exists')
})
test('DuplicateKeyError message includes [List] prefix and key', () => {
@@ -603,9 +601,7 @@ describe('List', () => {
test('computed signals should react to list changes', () => {
const list = createList([1, 2, 3])
- const sum = createMemo(() =>
- list.get().reduce((acc, n) => acc + n, 0),
- )
+ const sum = createMemo(() => list.get().reduce((acc, n) => acc + n, 0))
expect(sum.get()).toBe(6)
list.add(4)
@@ -991,6 +987,99 @@ describe('List', () => {
})
})
+ describe('mutation tracking leak', () => {
+ // Mutation methods read child item signals internally (e.g. to diff
+ // old vs. new values). Those reads must be untracked — otherwise the
+ // caller's effect silently gains edges to every item signal touched,
+ // causing over-broad re-runs on unrelated item mutations.
+ //
+ // The leak has two observable modes:
+ // (a) persistent — leaked edges survive and a later item mutation
+ // re-runs the effect even though it never read the list.
+ // (b) transient — the mutation itself propagates through the just-
+ // leaked edge and re-runs the effect once during setup; after
+ // that re-run trimSources removes the edges.
+
+ test('sort() inside an effect does not leak item-signal edges', () => {
+ const list = createList([3, 1, 2])
+ const trigger = createState(0)
+ let runs = 0
+ createEffect((): undefined => {
+ trigger.get()
+ runs++
+ if (runs === 1) list.sort()
+ })
+
+ expect(runs).toBe(1)
+
+ // Mutating an item value must NOT re-run the effect — it only
+ // ever read `trigger`, not the list. (persistent leak, mode a)
+ // biome-ignore lint/style/noNonNullAssertion: default key
+ list.byKey('0')!.set(99)
+ expect(runs).toBe(1)
+ })
+
+ test('splice() inside an effect does not leak item-signal edges', () => {
+ const list = createList([1, 2, 3, 4])
+ const trigger = createState(0)
+ let runs = 0
+ createEffect((): undefined => {
+ trigger.get()
+ runs++
+ if (runs === 1) list.splice(1, 2) // reads keys '1','2'
+ })
+
+ expect(runs).toBe(1)
+
+ // (persistent leak, mode a) — surviving items must not re-run
+ // biome-ignore lint/style/noNonNullAssertion: default key
+ list.byKey('0')!.set(99)
+ expect(runs).toBe(1)
+ })
+
+ test('update() inside an effect does not transiently re-run', () => {
+ // list.update calls list.get() (tracked) then list.set(). The
+ // tracked get() leaks an effect->list edge; the subsequent set()
+ // propagates through it, re-running the effect once during setup
+ // (transient leak, mode b).
+ const list = createList([1, 2, 3])
+ const trigger = createState(0)
+ let runs = 0
+ createEffect((): undefined => {
+ trigger.get()
+ runs++
+ if (runs === 1) list.update(arr => [...arr, 4])
+ })
+
+ // Without the fix, runs is 2 here (transient re-run).
+ expect(runs).toBe(1)
+ })
+
+ test('set() inside an effect does not leak item-signal edges (dirty node)', () => {
+ // A prior add() marks the node DIRTY, so set() takes the
+ // buildValue() path — reading each item signal tracked and
+ // leaking edges directly into the effect.
+ const list = createList([1, 2, 3])
+ const trigger = createState(0)
+ let runs = 0
+ createEffect((): undefined => {
+ trigger.get()
+ runs++
+ if (runs === 1) {
+ list.add(4) // marks node DIRTY
+ list.set([1, 2, 3, 4])
+ }
+ })
+
+ expect(runs).toBe(1)
+
+ // (persistent leak, mode a)
+ // biome-ignore lint/style/noNonNullAssertion: default key
+ list.byKey('0')!.set(99)
+ expect(runs).toBe(1)
+ })
+ })
+
describe('Undefined handling', () => {
// `undefined` elements must be rejected the same way `null` is — otherwise
// `keys` grows sparse and `length` / `get()` disagree.
@@ -1048,9 +1137,6 @@ test('Type Inference for custom createItem', () => {
? true
: false
type _Test = Expect<
- Equal<
- typeof byKey,
- ReturnType> | undefined
- >
+ Equal> | undefined>
>
})
diff --git a/test/memo.test.ts b/test/memo.test.ts
index 05bfb37..54fb34d 100644
--- a/test/memo.test.ts
+++ b/test/memo.test.ts
@@ -362,13 +362,9 @@ describe('Memo', () => {
describe('Input Validation', () => {
test('should throw InvalidCallbackError for non-function callback', () => {
// @ts-expect-error - Testing invalid input
- expect(() => createMemo(null)).toThrow(
- '[Memo] Callback null is invalid',
- )
+ expect(() => createMemo(null)).toThrow('[Memo] Callback null is invalid')
// @ts-expect-error - Testing invalid input
- expect(() => createMemo(42)).toThrow(
- '[Memo] Callback 42 is invalid',
- )
+ expect(() => createMemo(42)).toThrow('[Memo] Callback 42 is invalid')
// @ts-expect-error - Testing invalid input
expect(() => createMemo('str')).toThrow(
'[Memo] Callback "str" is invalid',
diff --git a/test/recipes.test.ts b/test/recipes.test.ts
index 30fb136..ebafff1 100644
--- a/test/recipes.test.ts
+++ b/test/recipes.test.ts
@@ -46,8 +46,7 @@ describe('Recipes', () => {
const wizardState = createMemo(() => {
const step = currentStep.get()
- const canProceed =
- step === 1 ? isStep1Valid.get() : isStep2Valid.get()
+ const canProceed = step === 1 ? isStep1Valid.get() : isStep2Valid.get()
const isComplete = step === totalSteps && canProceed
return {
@@ -59,10 +58,7 @@ describe('Recipes', () => {
})
function nextStep() {
- if (
- wizardState.get().canProceed &&
- currentStep.get() < totalSteps
- ) {
+ if (wizardState.get().canProceed && currentStep.get() < totalSteps) {
currentStep.update(s => s + 1)
}
}
@@ -163,9 +159,7 @@ describe('Recipes', () => {
applyComplexServerSync({
removed: ['w2'],
modified: [{ id: 'w1', newMembers: ['Alice', 'Bob', 'Dave'] }],
- added: [
- { id: 'w3', name: 'Marketing', members: ['Eve'], active: true },
- ],
+ added: [{ id: 'w3', name: 'Marketing', members: ['Eve'], active: true }],
})
expect(totalCount.get()).toBe(4)
diff --git a/test/regression-performance.test.ts b/test/regression-performance.test.ts
index 3ccb043..040167d 100644
--- a/test/regression-performance.test.ts
+++ b/test/regression-performance.test.ts
@@ -109,9 +109,7 @@ describe('Performance — primitive nodes', () => {
const branches = Array.from({ length: 5 }, () =>
f.createMemo(() => head.get() + 1),
)
- const sum = f.createMemo(() =>
- branches.reduce((a, b) => a + b.get(), 0),
- )
+ const sum = f.createMemo(() => branches.reduce((a, b) => a + b.get(), 0))
f.createEffect(() => {
sum.get()
})
@@ -233,9 +231,7 @@ describe('Performance — composite nodes', () => {
test('derived collection item update (2000 iterations)', () => {
const setup = (f: typeof current) => () => {
- const list = f.createList(
- Array.from({ length: 5 }, (_, i) => i),
- )
+ const list = f.createList(Array.from({ length: 5 }, (_, i) => i))
const derived = list.deriveCollection((v: number) => v * 2)
// biome-ignore lint/style/noNonNullAssertion: list is pre-populated
const firstKey = list.keyAt(0)!
diff --git a/test/slot.test.ts b/test/slot.test.ts
index c57f665..3b074e3 100644
--- a/test/slot.test.ts
+++ b/test/slot.test.ts
@@ -254,20 +254,20 @@ describe('Slot', () => {
get: () => state.get() * 2,
set: (val: number) => state.set(val / 2),
})
-
+
expect(slot.get()).toBe(2)
-
+
let runs = 0
createEffect(() => {
slot.get()
runs++
})
expect(runs).toBe(1)
-
+
state.set(5)
expect(runs).toBe(2)
expect(slot.get()).toBe(10)
-
+
slot.set(100)
expect(state.get()).toBe(50)
expect(runs).toBe(3)
@@ -326,7 +326,7 @@ describe('Slot', () => {
const slot = createSlot(state, {
// Only compare by x — changes to y should not propagate.
// First call receives undefined prev, so guard against that.
- equals: (a, b) => b == null ? false : a.x === b.x,
+ equals: (a, b) => (b == null ? false : a.x === b.x),
})
let runs = 0
let seenX = 0
diff --git a/test/state.test.ts b/test/state.test.ts
index 9529733..d23d866 100644
--- a/test/state.test.ts
+++ b/test/state.test.ts
@@ -97,10 +97,7 @@ describe('State', () => {
describe('options.equals', () => {
test('should use custom equality function to skip updates', () => {
- const state = createState(
- { x: 1 },
- { equals: (a, b) => a.x === b.x },
- )
+ const state = createState({ x: 1 }, { equals: (a, b) => a.x === b.x })
let effectCount = 0
createEffect(() => {
state.get()
@@ -142,9 +139,7 @@ describe('State', () => {
const state = createState(1, {
guard: (v): v is number => typeof v === 'number' && v > 0,
})
- expect(() => state.set(0)).toThrow(
- '[State] Signal value 0 is invalid',
- )
+ expect(() => state.set(0)).toThrow('[State] Signal value 0 is invalid')
expect(state.get()).toBe(1) // unchanged
})
diff --git a/test/store.test.ts b/test/store.test.ts
index 5ceaf06..7bed099 100644
--- a/test/store.test.ts
+++ b/test/store.test.ts
@@ -5,6 +5,7 @@ import {
createScope,
createState,
createStore,
+ InvalidStoreMutationError,
isList,
isState,
isStore,
@@ -384,13 +385,11 @@ describe('Store', () => {
test('should maintain property key ordering', () => {
const config = createStore({ alpha: 1, beta: 2, gamma: 3 })
const entries = [...config]
- expect(entries.map(([key, signal]) => [key, signal.get()])).toEqual(
- [
- ['alpha', 1],
- ['beta', 2],
- ['gamma', 3],
- ],
- )
+ expect(entries.map(([key, signal]) => [key, signal.get()])).toEqual([
+ ['alpha', 1],
+ ['beta', 2],
+ ['gamma', 3],
+ ])
})
})
@@ -657,6 +656,31 @@ describe('Store', () => {
})
})
+ describe('update re-subscription leak', () => {
+ // store.update calls store.get() (tracked) then store.set(). The
+ // tracked get() leaks an effect->store edge; the subsequent set()
+ // propagates through it, re-running the effect once during setup
+ // (transient leak). After the fix, update reads the current value
+ // untracked, matching State.update (which reads node.value directly).
+ test('effect calling store.update should not transiently re-run', () => {
+ const store = createStore<{ name: string; age: number }>({
+ name: 'Alice',
+ age: 30,
+ })
+ const trigger = createState(0)
+ let runs = 0
+ createEffect((): undefined => {
+ trigger.get()
+ runs++
+ if (runs === 1) store.update(prev => ({ ...prev, age: 31 }))
+ })
+
+ // Without the fix, runs is 2 here (transient re-run).
+ expect(runs).toBe(1)
+ expect(store.age.get()).toBe(31)
+ })
+ })
+
describe('set type-change routing', () => {
// When a property changes shape (primitive -> array), store.set must
// route through addSignal/createList, not stuff the array into the
@@ -686,4 +710,71 @@ describe('Store', () => {
expect(store.count.get()).toBe(5)
})
})
+
+ describe('proxy write guard', () => {
+ test('direct property assignment throws InvalidStoreMutationError and leaves state intact', () => {
+ const store = createStore({ name: 'Alice' })
+ expect(() => {
+ // @ts-expect-error deliberate misuse
+ store.name = 'Bob'
+ }).toThrow(InvalidStoreMutationError)
+ // The State signal is not shadowed
+ expect(isState(store.name)).toBe(true)
+ expect(store.name.get()).toBe('Alice')
+ // The reactive value is unchanged
+ expect(store.get()).toEqual({ name: 'Alice' })
+ })
+
+ test('delete via proxy throws and leaves state intact', () => {
+ const store = createStore<{ name?: string }>({ name: 'Alice' })
+ expect(() => {
+ delete store.name
+ }).toThrow(InvalidStoreMutationError)
+ expect(store.get()).toEqual({ name: 'Alice' })
+ // remove() still works after the failed delete
+ store.remove('name')
+ expect(store.get()).toEqual({})
+ })
+
+ test('Object.assign throws (routes through the set trap)', () => {
+ const store = createStore({ name: 'Alice' })
+ expect(() => Object.assign(store, { name: 'Bob' })).toThrow(
+ InvalidStoreMutationError,
+ )
+ expect(store.name.get()).toBe('Alice')
+ })
+
+ test('Object.defineProperty throws', () => {
+ const store = createStore<{ name: string; x?: number }>({
+ name: 'Alice',
+ })
+ expect(() => Object.defineProperty(store, 'x', { value: 1 })).toThrow(
+ InvalidStoreMutationError,
+ )
+ expect(store.get()).toEqual({ name: 'Alice' })
+ })
+
+ test('error messages name the correct alternative API', () => {
+ const store = createStore<{ name?: string }>({ name: 'Alice' })
+ expect(() => {
+ // @ts-expect-error deliberate misuse
+ store.name = 'Bob'
+ }).toThrow(
+ '[Store] Cannot assign to property "name" directly — use store.name.set(value), store.set(next), or store.add(key, value)',
+ )
+ expect(() => {
+ delete store.name
+ }).toThrow(
+ '[Store] Cannot delete property "name" directly — use store.remove("name")',
+ )
+ })
+
+ test('symbol property assignment throws', () => {
+ const store = createStore<{ name: string }>({ name: 'Alice' })
+ expect(() => {
+ // @ts-expect-error deliberate misuse with symbol key
+ store[Symbol.for('x')] = 1
+ }).toThrow(InvalidStoreMutationError)
+ })
+ })
})
diff --git a/test/task.test.ts b/test/task.test.ts
index 915167d..fc9ee95 100644
--- a/test/task.test.ts
+++ b/test/task.test.ts
@@ -462,13 +462,9 @@ describe('Task', () => {
test('should throw InvalidCallbackError for non-function callback', () => {
// @ts-expect-error - Testing invalid input
- expect(() => createTask(null)).toThrow(
- '[Task] Callback null is invalid',
- )
+ expect(() => createTask(null)).toThrow('[Task] Callback null is invalid')
// @ts-expect-error - Testing invalid input
- expect(() => createTask(42)).toThrow(
- '[Task] Callback 42 is invalid',
- )
+ expect(() => createTask(42)).toThrow('[Task] Callback 42 is invalid')
})
test('should throw NullishSignalValueError for null initial value', () => {
@@ -519,9 +515,7 @@ describe('Task', () => {
}
expect(caught).toBeInstanceOf(Error)
expect((caught as Error).message).toBe('sync boom')
- expect((caught as Error).message).not.toContain(
- 'Circular dependency',
- )
+ expect((caught as Error).message).not.toContain('Circular dependency')
})
})
diff --git a/test/unown.test.ts b/test/unown.test.ts
index 6c408f6..6315ec7 100644
--- a/test/unown.test.ts
+++ b/test/unown.test.ts
@@ -1,19 +1,18 @@
import { describe, expect, test } from 'bun:test'
-import {
- createEffect,
- createScope,
- createState,
- unown,
-} from '../index.ts'
+import { createEffect, createScope, createState, unown } from '../index.ts'
describe('createScope with root option', () => {
-
test('root scope is not registered on enclosing scope', () => {
let rootCleanupRan = false
const outerDispose = createScope(() => {
- createScope(() => {
- return () => { rootCleanupRan = true }
- }, { root: true })
+ createScope(
+ () => {
+ return () => {
+ rootCleanupRan = true
+ }
+ },
+ { root: true },
+ )
})
outerDispose()
expect(rootCleanupRan).toBe(false)
@@ -26,9 +25,14 @@ describe('createScope with root option', () => {
const outerDispose = createScope(() => {
createEffect((): undefined => {
trigger.get()
- createScope(() => {
- return () => { rootCleanupRuns++ }
- }, { root: true })
+ createScope(
+ () => {
+ return () => {
+ rootCleanupRuns++
+ }
+ },
+ { root: true },
+ )
})
})
@@ -47,12 +51,17 @@ describe('createScope with root option', () => {
let componentCleanupRuns = 0
const connectComponent = () =>
- createScope(() => {
- createEffect((): undefined => {
- componentEffectRuns++
- })
- return () => { componentCleanupRuns++ }
- }, { root: true })
+ createScope(
+ () => {
+ createEffect((): undefined => {
+ componentEffectRuns++
+ })
+ return () => {
+ componentCleanupRuns++
+ }
+ },
+ { root: true },
+ )
const outerDispose = createScope(() => {
createEffect((): undefined => {
@@ -73,9 +82,14 @@ describe('createScope with root option', () => {
test('dispose returned from root scope still works', () => {
let cleanupRan = false
- const dispose = createScope(() => {
- return () => { cleanupRan = true }
- }, { root: true })
+ const dispose = createScope(
+ () => {
+ return () => {
+ cleanupRan = true
+ }
+ },
+ { root: true },
+ )
expect(cleanupRan).toBe(false)
dispose()
expect(cleanupRan).toBe(true)
@@ -85,12 +99,15 @@ describe('createScope with root option', () => {
const source = createState('a')
let effectRuns = 0
- const dispose = createScope(() => {
- createEffect((): undefined => {
- source.get()
- effectRuns++
- })
- }, { root: true })
+ const dispose = createScope(
+ () => {
+ createEffect((): undefined => {
+ source.get()
+ effectRuns++
+ })
+ },
+ { root: true },
+ )
expect(effectRuns).toBe(1)
source.set('b')
@@ -105,22 +122,27 @@ describe('createScope with root option', () => {
let postCleanupRan = false
const outerDispose = createScope(() => {
try {
- createScope(() => { throw new Error('boom') }, { root: true })
+ createScope(
+ () => {
+ throw new Error('boom')
+ },
+ { root: true },
+ )
} catch {
// swallow
}
createScope(() => {
- return () => { postCleanupRan = true }
+ return () => {
+ postCleanupRan = true
+ }
})
})
outerDispose()
expect(postCleanupRan).toBe(true)
})
-
})
describe('unown', () => {
-
test('should return the value of the callback', () => {
const result = unown(() => 42)
expect(result).toBe(42)
@@ -128,7 +150,9 @@ describe('unown', () => {
test('should run the callback immediately and synchronously', () => {
let ran = false
- unown(() => { ran = true })
+ unown(() => {
+ ran = true
+ })
expect(ran).toBe(true)
})
@@ -137,7 +161,9 @@ describe('unown', () => {
const outerDispose = createScope(() => {
unown(() => {
createScope(() => {
- return () => { innerCleanupRan = true }
+ return () => {
+ innerCleanupRan = true
+ }
})
})
})
@@ -154,7 +180,9 @@ describe('unown', () => {
trigger.get()
unown(() => {
createScope(() => {
- return () => { innerCleanupRuns++ }
+ return () => {
+ innerCleanupRuns++
+ }
})
})
})
@@ -174,14 +202,17 @@ describe('unown', () => {
let componentEffectRuns = 0
let componentCleanupRuns = 0
- const connectComponent = () => unown(() =>
- createScope(() => {
- createEffect((): undefined => {
- componentEffectRuns++
- })
- return () => { componentCleanupRuns++ }
- })
- )
+ const connectComponent = () =>
+ unown(() =>
+ createScope(() => {
+ createEffect((): undefined => {
+ componentEffectRuns++
+ })
+ return () => {
+ componentCleanupRuns++
+ }
+ }),
+ )
const outerDispose = createScope(() => {
createEffect((): undefined => {
@@ -210,7 +241,7 @@ describe('unown', () => {
source.get()
effectRuns++
})
- })
+ }),
)
expect(effectRuns).toBe(1)
@@ -226,8 +257,10 @@ describe('unown', () => {
let cleanupRan = false
const dispose = unown(() =>
createScope(() => {
- return () => { cleanupRan = true }
- })
+ return () => {
+ cleanupRan = true
+ }
+ }),
)
expect(cleanupRan).toBe(false)
dispose()
@@ -240,7 +273,9 @@ describe('unown', () => {
unown(() => {
unown(() => {
createScope(() => {
- return () => { innerCleanupRan = true }
+ return () => {
+ innerCleanupRan = true
+ }
})
})
})
@@ -252,9 +287,13 @@ describe('unown', () => {
test('restores the active owner after the callback completes', () => {
let postCleanupRan = false
const outerDispose = createScope(() => {
- unown(() => { /* some unowned work */ })
+ unown(() => {
+ /* some unowned work */
+ })
createScope(() => {
- return () => { postCleanupRan = true }
+ return () => {
+ postCleanupRan = true
+ }
})
})
outerDispose()
@@ -265,12 +304,16 @@ describe('unown', () => {
let postCleanupRan = false
const outerDispose = createScope(() => {
try {
- unown(() => { throw new Error('boom') })
+ unown(() => {
+ throw new Error('boom')
+ })
} catch {
// swallow
}
createScope(() => {
- return () => { postCleanupRan = true }
+ return () => {
+ postCleanupRan = true
+ }
})
})
outerDispose()
@@ -288,5 +331,4 @@ describe('unown', () => {
expect(ran).toBe(true)
expect(typeof dispose).toBe('function')
})
-
})
diff --git a/test/util/core-entry.ts b/test/util/core-entry.ts
index a5c032d..9b93d87 100644
--- a/test/util/core-entry.ts
+++ b/test/util/core-entry.ts
@@ -4,7 +4,12 @@
* path a real consumer uses — and actually wires the four core signal types
* together so the bundler can't eliminate code a real usage would retain.
*/
-import { createEffect, createMemo, createState, createTask } from '../../index.ts'
+import {
+ createEffect,
+ createMemo,
+ createState,
+ createTask,
+} from '../../index.ts'
const count = createState(0)
const doubled = createMemo(() => count.get() * 2)
diff --git a/test/util/dependency-graph.ts b/test/util/dependency-graph.ts
index 4c42a0a..93068a7 100644
--- a/test/util/dependency-graph.ts
+++ b/test/util/dependency-graph.ts
@@ -24,9 +24,7 @@ export function makeGraph(
const { width, totalLayers, staticFraction, nSources } = config
return framework.withBuild(() => {
- const sources = new Array(width)
- .fill(0)
- .map((_, i) => framework.signal(i))
+ const sources = new Array(width).fill(0).map((_, i) => framework.signal(i))
const rows = makeDependentRows(
sources,
totalLayers - 1,
diff --git a/tsconfig.build.json b/tsconfig.build.json
index 55b4f8b..7f0ab99 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -4,8 +4,8 @@
"noEmit": false,
"declaration": true,
"declarationDir": "./types",
- "emitDeclarationOnly": true,
+ "emitDeclarationOnly": true
},
"include": ["./index.ts", "./src/**/*.ts"],
- "exclude": ["node_modules", "types", "test", "archive"],
+ "exclude": ["node_modules", "types", "test", "archive"]
}
diff --git a/tsconfig.json b/tsconfig.json
index 03f3826..81a6c8d 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -37,8 +37,8 @@
/* Performance */
"incremental": true,
- "tsBuildInfoFile": "./.tsbuildinfo",
+ "tsBuildInfoFile": "./.tsbuildinfo"
},
"include": ["./**/*.ts"],
- "exclude": ["node_modules", "types", "index.js"],
+ "exclude": ["node_modules", "types", "index.js"]
}
diff --git a/types/index.d.ts b/types/index.d.ts
index ff25a8d..ee31d81 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -3,7 +3,7 @@
* @version 1.3.4
* @author Esther Brunner
*/
-export { CircularDependencyError, DuplicateKeyError, type Guard, InvalidCallbackError, InvalidSignalValueError, NullishSignalValueError, PromiseValueError, ReadonlySignalError, RequiredOwnerError, UnsetSignalValueError, } from './src/errors';
+export { CircularDependencyError, DuplicateKeyError, EffectConvergenceError, type Guard, InvalidCallbackError, InvalidSignalValueError, InvalidStoreMutationError, NullishSignalValueError, PromiseValueError, ReadonlySignalError, RequiredOwnerError, UnsetSignalValueError, } from './src/errors';
/** @deprecated Use `DEEP_EQUALITY` instead. */
export { batch, type Cleanup, type ComputedOptions, createScope, DEEP_EQUALITY, DEFAULT_EQUALITY, type EffectCallback, isEqual, type MaybeCleanup, type MemoCallback, type ScopeOptions, type Signal, type SignalOptions, SKIP_EQUALITY, type TaskCallback, unown, untrack, } from './src/graph';
export { type Collection, type CollectionCallback, type CollectionChanges, type CollectionOptions, createCollection, type DeriveCollectionCallback, isCollection, } from './src/nodes/collection';
diff --git a/types/src/errors.d.ts b/types/src/errors.d.ts
index 05b650e..8c70553 100644
--- a/types/src/errors.d.ts
+++ b/types/src/errors.d.ts
@@ -18,6 +18,17 @@ declare class CircularDependencyError extends Error {
*/
constructor(where: string);
}
+/**
+ * Error thrown when queued effects keep re-triggering each other without settling.
+ */
+declare class EffectConvergenceError extends Error {
+ /**
+ * Constructs a new EffectConvergenceError.
+ *
+ * @param passes - The number of flush passes that ran without the graph settling.
+ */
+ constructor(passes: number);
+}
/**
* Error thrown when a signal value is null or undefined.
*/
@@ -97,8 +108,20 @@ declare class PromiseValueError extends TypeError {
declare class DuplicateKeyError extends Error {
constructor(where: string, key: string, value?: unknown);
}
+/**
+ * Error thrown when a Store property is assigned, deleted, or defined directly via the proxy.
+ */
+declare class InvalidStoreMutationError extends TypeError {
+ /**
+ * Constructs a new InvalidStoreMutationError.
+ *
+ * @param prop - The property name that was directly mutated.
+ * @param action - The kind of mutation attempted (`'assign to'`, `'delete'`, or `'define'`).
+ */
+ constructor(prop: string, action: 'assign to' | 'delete' | 'define');
+}
declare function validateSignalValue(where: string, value: unknown, guard?: Guard): asserts value is T;
declare function validateReadValue(where: string, value: T | null | undefined): asserts value is T;
declare function validateCallback(where: string, value: unknown): asserts value is (...args: unknown[]) => unknown;
declare function validateCallback(where: string, value: unknown, guard: (value: unknown) => value is T): asserts value is T;
-export { type Guard, CircularDependencyError, NullishSignalValueError, InvalidSignalValueError, UnsetSignalValueError, InvalidCallbackError, ReadonlySignalError, RequiredOwnerError, DuplicateKeyError, PromiseValueError, validateSignalValue, validateReadValue, validateCallback, };
+export { type Guard, CircularDependencyError, EffectConvergenceError, NullishSignalValueError, InvalidSignalValueError, UnsetSignalValueError, InvalidCallbackError, InvalidStoreMutationError, ReadonlySignalError, RequiredOwnerError, DuplicateKeyError, PromiseValueError, validateSignalValue, validateReadValue, validateCallback, };
diff --git a/types/src/graph.d.ts b/types/src/graph.d.ts
index e95f036..62a0809 100644
--- a/types/src/graph.d.ts
+++ b/types/src/graph.d.ts
@@ -189,6 +189,11 @@ declare function runCleanup(owner: OwnerNode): void;
declare function runEffect(node: EffectNode): void;
declare function refresh(node: SinkNode): void;
declare function flush(): void;
+/**
+ * Enqueues an effect that is still dirty after running (it wrote to its own
+ * dependencies) and, outside a batch, flushes until the graph converges.
+ */
+declare function scheduleEffect(node: EffectNode): void;
/**
* Batches multiple signal updates together.
* Effects will not run until the batch completes.
@@ -286,4 +291,4 @@ declare function createScope(fn: () => MaybeCleanup, options?: ScopeOptions): Cl
*/
declare function unown(fn: () => T): T;
declare function makeSubscribe(node: SourceNode, onWatch?: () => Cleanup): () => void;
-export { type Cleanup, type ComputedOptions, type EffectCallback, type EffectNode, type MaybeCleanup, type MemoCallback, type MemoNode, type Scope, type ScopeOptions, type Signal, type SignalOptions, type SinkNode, type StateNode, type TaskCallback, type TaskNode, activeOwner, activeSink, batch, batchDepth, createScope, DEFAULT_EQUALITY, DEEP_EQUALITY, isEqual, SKIP_EQUALITY, FLAG_CHECK, FLAG_CLEAN, FLAG_DIRTY, FLAG_RELINK, flush, link, makeSubscribe, propagate, refresh, registerCleanup, runCleanup, runEffect, setState, trimSources, TYPE_COLLECTION, TYPE_LIST, TYPE_MEMO, TYPE_SENSOR, TYPE_STATE, TYPE_SLOT, TYPE_STORE, TYPE_TASK, unlink, unown, untrack, };
+export { type Cleanup, type ComputedOptions, type EffectCallback, type EffectNode, type MaybeCleanup, type MemoCallback, type MemoNode, type Scope, type ScopeOptions, type Signal, type SignalOptions, type SinkNode, type StateNode, type TaskCallback, type TaskNode, activeOwner, activeSink, batch, batchDepth, createScope, DEFAULT_EQUALITY, DEEP_EQUALITY, isEqual, SKIP_EQUALITY, FLAG_CHECK, FLAG_CLEAN, FLAG_DIRTY, FLAG_RELINK, flush, link, makeSubscribe, propagate, refresh, registerCleanup, runCleanup, runEffect, scheduleEffect, setState, trimSources, TYPE_COLLECTION, TYPE_LIST, TYPE_MEMO, TYPE_SENSOR, TYPE_STATE, TYPE_SLOT, TYPE_STORE, TYPE_TASK, unlink, unown, untrack, };
diff --git a/types/src/nodes/effect.d.ts b/types/src/nodes/effect.d.ts
index ecf1361..41fb27c 100644
--- a/types/src/nodes/effect.d.ts
+++ b/types/src/nodes/effect.d.ts
@@ -38,9 +38,16 @@ type SingleMatchHandlers = {
* Effects run immediately upon creation and re-run when any tracked signal changes.
* Effects are executed during the flush phase, after all updates have been batched.
*
+ * An effect that writes to a signal it also depends on re-runs until the graph
+ * settles, so its last run always observes the final signal values. Effect graphs
+ * that never settle (e.g. an unconditional self-increment, or two effects writing
+ * each other's dependencies) throw `EffectConvergenceError` after a bounded number
+ * of flush passes instead of looping forever.
+ *
* @since 0.1.0
* @param fn - The effect function that can track dependencies and register cleanup callbacks
* @returns A cleanup function that can be called to dispose of the effect
+ * @throws EffectConvergenceError If effects keep re-triggering each other without settling
*
* @example
* ```ts
diff --git a/types/src/nodes/store.d.ts b/types/src/nodes/store.d.ts
index 8d22e23..9f49896 100644
--- a/types/src/nodes/store.d.ts
+++ b/types/src/nodes/store.d.ts
@@ -49,6 +49,18 @@ type Store = BaseStore & {
* user.name.set('Bob'); // Only name subscribers react
* console.log(user.get()); // { name: 'Bob', age: 30 }
* ```
+ *
+ * Direct property assignment, deletion, or `Object.defineProperty` through the
+ * proxy throws `InvalidStoreMutationError` — use `store.key.set(value)`,
+ * `store.set(next)`, `store.add(key, value)`, or `store.remove(key)` instead.
+ * Properties are typed as signals (not raw values) so destructuring preserves
+ * reactivity; this means proxy assignment is a compile-time error for typed
+ * stores. The runtime guard extends that protection to `any`-typed access,
+ * JS consumers, and `Object.assign`. See ADR-0017 for the full rationale.
+ *
+ * Note: a data key named like a base method (`get`, `set`, `keys`, `update`,
+ * `add`, `remove`, `byKey`) shadows the method via proxy access. Use
+ * `store.byKey(key)` to reach such a property.
*/
declare function createStore(value: T, options?: StoreOptions): Store;
/**