refactoring + emit patches in our set format

main
Laurin Weger 1 week ago
parent 58e5e96fa3
commit 68f9ec498c
No known key found for this signature in database
GPG Key ID: 9B372BB0B792770F
  1. 217
      README.md
  2. 39
      src/core.ts
  3. 736
      src/deepSignal.ts
  4. 0
      src/test/core.test.ts
  5. 4
      src/test/index.test.ts
  6. 47
      src/test/patchOptimized.test.ts
  7. 99
      src/test/tier3.test.ts
  8. 33
      src/test/watch.test.ts
  9. 247
      src/test/watchPatches.test.ts
  10. 390
      src/watch.ts
  11. 38
      src/watchEffect.ts

@ -1,124 +1,167 @@
# 🧶 AlienDeepSignals
# alien-deepsignals
Use [alien-signals](https://github.com/stackblitz/alien-signals) with the interface of a plain JavaScript object.
Deep structural reactivity for plain objects / arrays / Sets built on top of `alien-signals`.
- **DeepSignal** works by wrapping the object with a `Proxy` that intercepts all property accesses and returns the signal value by default.
- This allows you to easily create a **deep object that can be observed for changes**, while still being able to **mutate the object normally**.
- Nested objects and arrays are also converted to deep signal objects/arrays, allowing you to create **fully reactive data structures**.
- The `$` prefix returns the signal instance: `state.$prop`.
## Credits
- The ability of deepsignal comes from [deepsignal](https://github.com/luisherranz/deepsignal), thank [luisherranz](https://github.com/luisherranz).
Core idea: wrap a data tree in a `Proxy` that lazily creates per-property signals the first time you read them. Accessing a property returns the plain value; accessing `$prop` returns the underlying signal function. Deep mutations emit compact batched patch objects you can observe with `watch()`.
## Features
- **Transparent**: `deepsignal` wraps the object with a proxy that intercepts all property accesses, but does not modify how you interact with the object. This means that you can still use the object as you normally would, and it will behave exactly as you would expect, except that mutating the object also updates the value of the underlying signals.
- **Tiny (less than 1kB)**: `deepsignal` is designed to be lightweight and has a minimal footprint, making it easy to include in your projects. It's just a small wrapper around `alien-signals`.
- **Full array support**: `deepsignal` fully supports arrays, including nested arrays.
- **Deep**: `deepsignal` converts nested objects and arrays to deep signal objects/arrays, allowing you to create fully reactive data structures.
- **Lazy initialization**: `deepsignal` uses lazy initialization, which means that signals and proxies are only created when they are accessed for the first time. This reduces the initialization time to almost zero and improves the overall performance in cases where you only need to observe a small subset of the object's properties.
- **Stable references**: `deepsignal` uses stable references, which means that the same `Proxy` instances will be returned for the same objects so they can exist in different places of the data structure, just like regular JavaScript objects.
- **Automatic derived state**: getters are automatically converted to computeds instead of signals.
- **TypeScript support**: `deepsignal` is written in TypeScript and includes type definitions, so you can use it seamlessly with your TypeScript projects, including access to the signal value through the prefix `state.$prop`.
- **State management**: `deepsignal` can be used as a state manager, including state and actions in the same object.
* Lazy: signals & child proxies created only when touched.
* Deep: nested objects, arrays, Sets proxied on demand.
* Per-property signals: fine‑grained invalidation without traversal on each change.
* Patch stream: microtask‑batched granular mutations (paths + op) for syncing external stores / framework adapters.
* Getter => computed: property getters become derived (readonly) signals automatically.
* `$` accessors: TypeScript exposes `$prop` for each non‑function key plus `$` / `$length` for arrays.
* Sets: structural `add/delete/clear` emit patches; object entries get synthetic stable ids (prefers `id` / `@id` fields or auto‑generated blank IDs).
* Shallow escape hatch: wrap sub-objects with `shallow(obj)` to track only reference replacement.
The most important feature is that **it just works**. You don't need to do anything special. Just create an object, mutate it normally and all your components will know when they need to rerender.
## Installation
## Install
```bash
npm install alien-deepsignals
pnpm add alien-deepsignals
# or
npm i alien-deepsignals
```
## Usage
## Quick start
```ts
import { deepSignal } from 'alien-deepsignals';
import { deepSignal } from 'alien-deepsignals'
const state = deepSignal({
count: 0,
name: 'John',
nested: {
deep: 'value',
},
array: [1, 2, 3],
});
state.count++;
state.$nested.value.deep = 'new value';
state.$array.value.push(4);
user: { name: 'Ada' },
items: [{ id: 'i1', qty: 1 }],
settings: new Set(['dark'])
})
state.count++ // mutate normally
state.user.name = 'Grace' // nested write
state.items.push({ id: 'i2', qty: 2 })
state.settings.add('beta')
// Direct signal access
state.$count!.set(5) // update via signal
console.log(state.$count!()) // read via signal function
```
### watch
## Watching patches
`watch(root, cb, options?)` observes a deepSignal root and invokes your callback with microtask‑batched mutation patches plus snapshots.
```ts
import { deepSignal, watch } from 'alien-deepsignals';
const state = deepSignal({
count: 0,
name: 'John',
nested: {
deep: 'value',
},
array: [1, 2, 3],
});
watch(state,(value)=>{
console.log(value);
},{
deep: true,
immediate: true,
// once
import { watch } from 'alien-deepsignals'
const stop = watch(state, ({ patches, oldValue, newValue }) => {
for (const p of patches) {
console.log(p.op, p.path.join('.'), 'value' in p ? p.value : p.type)
}
})
state.user.name = 'Lin'
state.items[0].qty = 3
await Promise.resolve() // flush microtask
stop()
```
### Callback event shape
```ts
type WatchPatchEvent<T> = {
patches: DeepPatch[] // empty only on immediate
oldValue: T | undefined // deep-cloned snapshot before batch
newValue: T // live proxy (already mutated)
registerCleanup(fn): void // register disposer for next batch/stop
stopListening(): void // unsubscribe
}
```
### Advanced watching & patch stream
### Options
There are two layers:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `immediate` | boolean | false | Fire once right away with `patches: []`. |
| `once` | boolean | false | Auto stop after first callback (immediate counts). |
1. `watch()` (high-level) – subscribe to value changes of signals, deepSignal objects, getters, or arrays of them. Supports options:
- `immediate` (fire once right away)
- `deep` (traverse nested properties to collect deps)
- `once` (auto-dispose after first emission)
- `patchOptimized` (when `deep` + deepSignal, skip full traversal and rely on internal mutation patches).
2. Patch stream (low-level) – internal `subscribeDeepMutations()` used by `patchOptimized` and exposed via new helpers below.
`observe()` is an alias of `watch()`.
#### patchOptimized
## DeepPatch format
Deep watches normally trigger a full recursive traversal to register dependencies, which can be expensive for large trees. With `patchOptimized: true`, a hidden version counter signal is incremented only when a relevant deep mutation patch batch is emitted. That means no repeated deep traversal per change—performance scales with number of actual mutations instead of tree size.
```ts
type DeepPatch = {
root: symbol // stable id per deepSignal root
path: (string | number)[] // root-relative segments
} & (
| { op: 'add'; type: 'object' } // assigned object/array/Set entry object
| { op: 'add'; value: string | number | boolean } // primitive write
| { op: 'remove' } // deletion
| { op: 'add'; type: 'set'; value: [] } // Set.clear()
| { op: 'add'; type: 'set'; value: (string|number|boolean)[] | { [id: string]: object } } // (reserved)
)
```
Notes:
* `type:'object'` omits value to avoid deep cloning; read from `newValue` if needed.
* `Set.add(entry)` emits object vs primitive form depending on entry type; path ends with synthetic id.
* `Set.clear()` emits one structural patch and suppresses per‑entry removals in same batch.
## Sets & synthetic ids
Object entries inside Sets need a stable key. Priority:
1. `entry.id`
2. `entry['@id']`
3. Custom via `setSetEntrySyntheticId(entry, 'myId')` before `add`
4. Auto `_bN` blank id
Helpers:
```ts
watch(state, (val) => {
render(val)
}, { deep: true, patchOptimized: true })
import { addWithId, setSetEntrySyntheticId } from 'alien-deepsignals'
setSetEntrySyntheticId(obj, 'custom')
state.settings.add(obj)
addWithId(state.settings as any, { x:1 }, 'x1')
```
### New helper APIs (optional sugar)
## Shallow
Skip deep proxying of a subtree (only reference replacement tracked):
```ts
import { watchPatches, observe } from 'alien-deepsignals'
import { shallow } from 'alien-deepsignals'
state.config = shallow({ huge: { blob: true } })
```
// 1. watchPatches: directly receive deep mutation patch batches for a deepSignal root
const stop = watchPatches(state, (patches) => {
for (const p of patches) {
console.log(p.type, p.path.join('.'), p.value)
}
})
## TypeScript ergonomics
// 2. observe: unified API for value or patch modes
// value mode (essentially watch())
const offValue = observe(state, (val, old) => {
console.log('value changed', old, '=>', val)
}, { mode: 'value', deep: true, patchOptimized: true })
`DeepSignal<T>` exposes both plain properties and optional `$prop` signal accessors (excluded for function members). Arrays add `$` (index signal map) and `$length`.
// patch mode (delegates to watchPatches)
const offPatches = observe(state, (patches) => {
console.log('patch batch', patches)
}, { mode: 'patch' })
```ts
const state = deepSignal({ count: 0, user: { name: 'A' } })
state.count++ // ok
state.$count!.set(9) // write via signal
const n: number = state.$count!() // typed number
```
Modes summary:
| API | Emits | Use-case |
|-----|-------|----------|
| `watch` / `observe(..., {mode:'value'})` | New value + old value | Derive computations, side effects |
| `watchPatches` / `observe(..., {mode:'patch'})` | Array of `{root,type,path,value}` | Sync external stores/UI diff |
## API surface
| Function | Description |
|----------|-------------|
| `deepSignal(obj)` | Create (or reuse) reactive deep proxy. |
| `watch(root, cb, opts?)` | Observe batched deep mutations. |
| `observe(root, cb, opts?)` | Alias of `watch`. |
| `peek(obj,key)` | Untracked property read. |
| `shallow(obj)` | Mark object to skip deep proxying. |
| `isDeepSignal(val)` | Runtime predicate. |
| `isShallow(val)` | Was value marked shallow. |
| `setSetEntrySyntheticId(obj,id)` | Assign custom Set entry id. |
| `addWithId(set, entry, id)` | Insert with desired synthetic id. |
| `subscribeDeepMutations(root, cb)` | Low-level patch stream (used by watch). |
## Credits
Inspired by [deepsignal](https://github.com/luisherranz/deepsignal) – thanks to @luisherranz. Re-imagined with patch batching & Set support.
## License
In patch mode only structural mutations trigger callbacks; reads do not cause traversal.
MIT

@ -1,35 +1,5 @@
/**
* Core signal utilities (v2 native layer + tiny ergonomic shims)
* ------------------------------------------------------------------
* We lean directly on alien-signals v2 which exposes function-form primitives:
* const count = signal(0) => invoke to read, invoke with arg to write.
* const doubled = computed(() => count() * 2)
* effect(() => console.log(doubled()))
*
* This file adds a very small façade so existing code/tests that relied on:
* - obj.$prop.value style access
* - signal.value / signal.peek()
* - (Optionally supported earlier) instanceof checks removed now for leaner runtime.
* still work while everything internally is the native function signal.
*
* Tagging strategy:
* - The created function is augmented with .value (getter/setter) and .peek() (non-tracking read alias).
* - A Symbol flag (ReactiveFlags_.IS_SIGNAL) is attached for runtime guards.
* - Prototype of the function is set to a dummy constructor (Signal / Computed) enabling instanceof checks.
*
* Effect wrapper:
* - The watch implementation historically expected a class with .run(), .stop(), .dirty & .scheduler.
* - We wrap a native alienEffect; every native re-run marks dirty=true then calls user getter + scheduler.
* - run() simply executes the getter without re-registering a new effect (leveraging the existing one).
*/
// Native v2 re-export with minimal tagging for .value convenience.
/**
* Raw native alien-signals v2 primitives re-exported for advanced consumers wanting
* to bypass the tagging helpers (exposing the bare function signals/computed/effect batch APIs).
*
* - `_rawSignal` / `_rawComputed` are the untagged constructors.
* - `effect`, `startBatch`, `endBatch`, `getCurrentSub`, `setCurrentSub` are passed through unchanged.
*/
/** Lightweight façade adding ergonomic helpers (.value/.peek/.get/.set) to native alien-signals function signals. */
// Native re-exports for advanced usage.
export {
signal as _rawSignal,
computed as _rawComputed,
@ -54,8 +24,13 @@ import { isFunction } from "./utils";
/** Internal shape of a tagged writable signal after adding ergonomic helpers. */
type TaggedSignal<T> = ReturnType<typeof alienSignal<T>> & {
/** Tracking read / write via property syntax */
value: T;
/** Non-tracking read */
peek(): T;
/** Alias for tracking read */
get(): T;
/** Write helper */
set(v: T): void;
};

@ -1,106 +1,62 @@
import { ReactiveFlags } from "./contents";
import { computed, signal, isSignal } from "./core";
/**
* Implementation overview (supplementary to header docs):
*
* Data structures:
* - proxyToSignals: WeakMap<proxy, Map<prop, signalFn>> maps each proxied object to its per-key signal.
* - objToProxy: WeakMap<rawObject, proxy> ensures stable proxy instance per raw object.
* - arrayToArrayOfSignals: WeakMap<array, proxy> holds special `$` array meta proxy (index signals + length).
* - ignore: WeakSet marks objects already proxied or shallow-wrapped to avoid double proxying.
* - objToIterable: WeakMap<object, signal<number>> used to re-trigger enumeration dependent computed/effects.
* - proxyMeta: WeakMap<proxy, { parent, key, root }> chain info for patch path reconstruction.
*
* Key decisions:
* - Signals are created lazily: first read of value or its `$` accessor.
* - Getter properties become computed signals (readonly) so derived values stay consistent.
* - Setting via `$prop` enforces passing a signal (allows external signal assignment path).
* - Deep patches reconstruct path on mutation (O(depth)); no bookkeeping per property upfront.
* - Arrays use `$` (returns array-of-signals proxy) & `$length` (length signal) while skipping function-valued entries.
*
* Performance characteristics:
* - Read of untouched property: ~O(1) creating one signal + potential child proxy.
* - Mutation: signal update + optional patch path build; batching coalesces multiple patches in same microtask.
* - Enumeration tracking: ownKeys/for..in increments a dedicated counter signal (objToIterable) to invalidate dependents.
*/
/**
* deepSignal()
* =====================================================================
* Core idea: Wrap a plain object/array tree in a Proxy that lazily creates
* per-property signals on first access. Property access without a `$` prefix
* returns the underlying value (tracking the read). Access with `$` (e.g. obj.$foo)
* returns the signal function itself. Arrays have special `$` for index signals and
* `$length` for the length signal.
*
* Why function signals? Native alien-signals v2 returns a function you call to read,
* call with an argument to write. We keep that but provide `.value` in tagging layer.
*
* Getter logic summary:
* 1. Determine if the caller wants a signal (prefix `$` or array meta) vs value.
* 2. If property is a getter on the original object, wrap it in a computed signal.
* 3. Otherwise create (once) a writable signal seeded with (possibly proxied) child value.
* 4. Return either the signal function or its invocation (value) depending on caller form.
*
* Setter logic summary:
* - Writes update the raw target via Reflect.set.
* - If the property signal already exists, update it; otherwise create it.
* - Array length & object key enumeration signals are nudged (length / ownKeys tracking).
* - A deep mutation patch is queued capturing: root id, type, path, new value.
*
* Patch batching:
* - Mutations push a patch into a per-microtask buffer (pendingPatches).
* - A queued microtask flush delivers the accumulated array to each subscriber.
* - This enables consumers (framework adapters) to materialize minimal changes.
*
* Metadata chain (proxyMeta): parent + key + root symbol; used to reconstruct full path
* for patches without storing full paths on every node.
*/
/**
* Deep mutation patch system (for Svelte rune integration / granular updates)
* -------------------------------------------------------------------------
* This augmention adds an optional patch stream that reports every deep mutation
* (set/delete) with a property path relative to the root deepSignal() object.
*
* Rationale: The core library already has per-property signals and is efficient
* for effect re-execution. However, consumers that want to mirror the entire
* nested structure into another reactive container (e.g. Svelte $state) gain
* from receiving a batch of fine-grained patches instead of re-cloning.
*
* Design:
* 1. Each proxy created by deepSignal has lightweight metadata (parent, key, root id).
* 2. On each mutation (set/delete) we reconstruct the path by walking parents.
* 3. Patches are batched in a microtask and delivered to subscribers.
* 4. Zero cost for projects that never call subscribeDeepMutations(): only minimal
* metadata storage and O(depth) path build per mutation.
* deepSignal: wrap an object / array / Set graph in lazy per-property signals plus an optional deep patch stream.
* - `$prop` returns a signal; plain prop returns its current value.
* - Getter props become computed signals.
* - Arrays expose `$` (index signals) & `$length`; Sets emit structural entry patches with synthetic ids.
* - subscribeDeepMutations(root, cb) batches set/delete ops per microtask (DeepPatch[]).
* - shallow(obj) skips deep proxying of a subtree.
*/
/**
* A granular description of a deep mutation originating from a {@link deepSignal} root.
* Patches are batched per microtask and delivered in order of occurrence.
*
* Invariants:
* - `path` is never empty (the root object itself is not represented by a patch without a key)
* - For `type === "delete"` the `value` field is omitted
* - For `type === "set"` the `value` is the postmutation (proxied if object/array/set) value snapshot
*/
export interface DeepPatch {
/** A batched deep mutation (set/add/remove) from a deepSignal root. */
export type DeepPatch = {
/** Unique identifier for the deep signal root which produced this patch. */
root: symbol;
/** Mutation kind applied at the resolved `path`. */
type: "set" | "delete";
/** Property path (array indices, object keys, synthetic Set entry ids) from the root to the mutated location. */
path: (string | number)[];
} & (
| DeepSetAddPatch
| DeepSetRemovePatch
| DeepObjectAddPatch
| DeepRemovePatch
| DeepLiteralAddPatch
);
export interface DeepSetAddPatch {
/** Mutation kind applied at the resolved `path`. */
op: "add";
type: "set";
/** New value for `set` mutations (omitted for `delete`). */
value?: any;
value: (number | string | boolean)[] | { [id: string]: object };
}
export interface DeepSetRemovePatch {
/** Mutation kind applied at the resolved `path`. */
op: "remove";
type: "set";
/** The value to be removed from the set. Either a literal or the key (id) of an object. */
value: string | number | boolean;
}
export interface DeepObjectAddPatch {
/** Mutation kind applied at the resolved `path`. */
op: "add";
type: "object";
}
/** Function signature for subscribers passed to {@link subscribeDeepMutations}. */
export interface DeepRemovePatch {
/** Mutation kind applied at the resolved `path`. */
op: "remove";
}
export interface DeepLiteralAddPatch {
/** Mutation kind applied at the resolved `path` */
op: "add";
/** The literal value to be added at the resolved `path` */
value: string | number | boolean;
}
/** Callback signature for subscribeDeepMutations. */
export type DeepPatchSubscriber = (patches: DeepPatch[]) => void;
/**
* Lightweight metadata stored per proxy enabling reconstruction of a property's full path
* at mutation time without eager bookkeeping of every descendant.
*/
/** Minimal per-proxy metadata for path reconstruction. */
interface ProxyMeta {
/** Parent proxy in the object graph (undefined for root). */
parent?: object;
@ -110,18 +66,15 @@ interface ProxyMeta {
root: symbol;
}
/** Internal lookup from proxy -> {@link ProxyMeta}. */
// Proxy -> metadata
const proxyMeta = new WeakMap<object, ProxyMeta>();
/** Global registry of batch mutation subscribers (filtered per root at delivery time). */
const mutationSubscribers = new Set<DeepPatchSubscriber>();
let pendingPatches: DeepPatch[] | null = null;
// Root symbol -> subscribers
const mutationSubscribers = new Map<symbol, Set<DeepPatchSubscriber>>();
// Pending patches grouped per root (flushed once per microtask)
let pendingPatches: Map<symbol, DeepPatch[]> | null = null;
let microtaskScheduled = false;
/** Sentinal constant for root id retrieval (exported for external helpers). */
/**
* Sentinel symbol used internally / by helpers to retrieve a deepSignal root id.
* You normally obtain a concrete root id via {@link getDeepSignalRootId} on a proxy instance.
*/
/** Sentinel symbol; get concrete root id via getDeepSignalRootId(proxy). */
export const DEEP_SIGNAL_ROOT_ID = Symbol("alienDeepSignalRootId");
function buildPath(
@ -141,69 +94,75 @@ function buildPath(
}
function queuePatch(patch: DeepPatch) {
if (!pendingPatches) pendingPatches = [];
pendingPatches.push(patch);
if (!pendingPatches) pendingPatches = new Map();
const root = patch.root;
let list = pendingPatches.get(root);
if (!list) {
list = [];
pendingPatches.set(root, list);
}
list.push(patch);
if (!microtaskScheduled) {
microtaskScheduled = true;
queueMicrotask(() => {
microtaskScheduled = false;
const batch = pendingPatches;
const groups = pendingPatches;
pendingPatches = null;
if (!batch || batch.length === 0) return;
mutationSubscribers.forEach((sub) => sub(batch));
if (!groups) return;
for (const [rootId, patches] of groups) {
if (!patches.length) continue;
const subs = mutationSubscribers.get(rootId);
if (subs) subs.forEach((cb) => cb(patches));
}
});
}
}
/**
* Register a mutation batch listener for all active deepSignal roots.
*
* Each microtask in which one or more deep mutations occur produces at most one callback
* invocation containing the ordered list of {@link DeepPatch} objects.
*
* @param sub Callback receiving a batch array (never empty) of deep patches.
* @returns An unsubscribe function that detaches the listener when invoked.
*/
export function subscribeDeepMutations(sub: DeepPatchSubscriber): () => void {
mutationSubscribers.add(sub);
return () => mutationSubscribers.delete(sub);
/** Subscribe to microtask-batched deep patches for a root (returns unsubscribe). */
export function subscribeDeepMutations(
root: object | symbol,
sub: DeepPatchSubscriber
): () => void {
const rootId = typeof root === "symbol" ? root : getDeepSignalRootId(root);
if (!rootId)
throw new Error(
"subscribeDeepMutations() expects a deepSignal root proxy or root id symbol"
);
let set = mutationSubscribers.get(rootId);
if (!set) {
set = new Set();
mutationSubscribers.set(rootId, set);
}
set.add(sub);
return () => {
const bucket = mutationSubscribers.get(rootId);
if (!bucket) return;
bucket.delete(sub);
if (bucket.size === 0) mutationSubscribers.delete(rootId);
};
}
/**
* Obtain the stable root id symbol for a given deepSignal proxy (or any nested proxy).
* Returns `undefined` if the value is not a deepSignal-managed proxy.
*
* @example
* const state = deepSignal({ a: { b: 1 } });
* const id1 = getDeepSignalRootId(state); // symbol
* const id2 = getDeepSignalRootId(state.a); // same symbol as id1
* getDeepSignalRootId({}) // undefined
*/
/** Return the stable root symbol for any deepSignal proxy (undefined if not one). */
export function getDeepSignalRootId(obj: any): symbol | undefined {
return proxyMeta.get(obj)?.root;
}
// Proxy -> Map of property name -> signal function
/** Proxy instance -> map of property name -> signal function (created lazily). */
/** Proxy -> Map<propertyName, signalFn> (lazy). */
const proxyToSignals = new WeakMap();
// Raw object/array/Set -> its stable proxy
// Raw object/array/Set -> stable proxy
const objToProxy = new WeakMap();
// Raw array -> special `$` proxy giving index signals
// Raw array -> `$` meta proxy with index signals
const arrayToArrayOfSignals = new WeakMap();
// Objects already proxied or intentionally shallow
// Objects already proxied or marked shallow
const ignore = new WeakSet();
// Object -> signal counter used for key enumeration invalidation
// Object -> signal counter for enumeration invalidation
const objToIterable = new WeakMap();
const rg = /^\$/;
const descriptor = Object.getOwnPropertyDescriptor;
let peeking = false;
// (Synthetic ID helper declarations were restored further below before usage.)
/**
* Deep array interface expressed as a type intersection to avoid structural extend
* incompatibilities with the native Array while still refining callback parameter
* types to their DeepSignal forms.
*/
// Deep array interface refining callback parameter types.
type DeepArray<T> = Array<T> & {
map: <U>(
callbackfn: (
@ -301,9 +260,8 @@ type DeepArray<T> = Array<T> & {
initialValue: U
): U;
};
// --- Synthetic ID helpers & ergonomics for Set entry patching (restored) ---
// Synthetic ids for Set entry objects (stable key for patches)
let __blankNodeCounter = 0;
/** User or auto-assigned synthetic id bookkeeping for Set entry objects. */
const setObjectIds = new WeakMap<object, string>();
const assignBlankNodeId = (obj: any) => {
if (setObjectIds.has(obj)) return setObjectIds.get(obj)!;
@ -311,7 +269,7 @@ const assignBlankNodeId = (obj: any) => {
setObjectIds.set(obj, id);
return id;
};
/** Assign (or override) synthetic identifier for an object prior to Set.add(). */
/** Assign (or override) synthetic id before Set.add(). */
export function setSetEntrySyntheticId(obj: object, id: string | number) {
setObjectIds.set(obj, String(id));
}
@ -332,9 +290,7 @@ const getSetEntryKey = (val: any): string | number => {
}
return val as any;
};
/**
* Insert into a (possibly proxied) Set with a desired synthetic id; returns proxied entry (objects) or primitive.
*/
/** Add entry with synthetic id; returns proxied object if applicable. */
export function addWithId<T extends object>(
set: Set<T>,
entry: T,
@ -349,24 +305,17 @@ export function addWithId(set: Set<any>, entry: any, id: string | number) {
return entry;
}
/** Determine whether a given value is a deepSignal-managed proxy (any depth). */
/** Is value a deepSignal-managed proxy? */
export const isDeepSignal = (source: any) => {
return proxyToSignals.has(source);
};
/** Predicate indicating whether a value was explicitly marked via {@link shallow}. */
/** Was value explicitly marked shallow? */
export const isShallow = (source: any) => {
return ignore.has(source);
};
/**
* Create (or retrieve) a deep reactive proxy for the supplied plain object / array / Set.
* All nested objects / arrays / Sets are wrapped lazily on first access; primitives are passed through.
*
* Root identity: multiple invocations with the same object return the same proxy; each distinct root
* owns a unique symbol id available via {@link getDeepSignalRootId} and present on every emitted patch.
*
*/
/** Create (or reuse) a deep reactive proxy for an object / array / Set. */
export const deepSignal = <T extends object>(obj: T): DeepSignal<T> => {
if (!shouldProxy(obj)) throw new Error("This object can't be observed.");
if (!objToProxy.has(obj)) {
@ -384,14 +333,7 @@ export const deepSignal = <T extends object>(obj: T): DeepSignal<T> => {
return objToProxy.get(obj);
};
/**
* Read a property on a deepSignal proxy without establishing reactive tracking.
* Equivalent conceptually to a non-tracking read / untracked() pattern.
*
* @param obj deepSignal proxy object
* @param key property key to read
* @returns The raw (possibly proxied) property value without dependency collection.
*/
/** Read property without tracking (untracked read). */
export const peek = <
T extends DeepSignalObject<object>,
K extends keyof RevertDeepSignalObject<T>
@ -408,13 +350,7 @@ export const peek = <
};
const shallowFlag = Symbol(ReactiveFlags.IS_SHALLOW);
/**
* Mark an object so that it will not be deeply proxied when used as a property value of
* another deepSignal tree. This is a shallow escape hatch for performance or semantic reasons.
*
* NOTE: The returned object itself is not reactive; only top-level assignment of the reference
* produces patches when attached to a deepSignal structure.
*/
/** Mark object to skip deep proxying (only reference changes tracked). */
export function shallow<T extends object>(obj: T): Shallow<T> {
ignore.add(obj);
return obj as Shallow<T>;
@ -434,212 +370,262 @@ const createProxy = (
return proxy;
};
const throwOnMutation = () => {
throw new Error(
"Don't mutate the signals directly (use the underlying property/value instead)."
);
};
/**
* Unified get trap factory.
* @param isArrayOfSignals indicates we are resolving properties on the special array `$` proxy.
*/
const get =
(isArrayOfSignals: boolean) =>
(target: object, fullKey: string, receiver: object): unknown => {
if (peeking) return Reflect.get(target, fullKey, receiver);
let returnSignal = isArrayOfSignals || fullKey[0] === "$";
// Special handling for Set instances: treat as atomic & emit structural + per-entry patches
if (target instanceof Set && typeof fullKey === "string") {
const raw = target as Set<any>;
const key = fullKey;
const meta = proxyMeta.get(receiver);
// Helper to proxy a single entry
const ensureEntryProxy = (entry: any) => {
// Set-specific access & structural patch emission.
function getFromSet(raw: Set<any>, key: string, receiver: object): any {
const meta = proxyMeta.get(receiver);
// Helper to proxy a single entry (object) & assign synthetic id if needed.
const ensureEntryProxy = (entry: any) => {
if (
entry &&
typeof entry === "object" &&
shouldProxy(entry) &&
!objToProxy.has(entry)
) {
const synthetic = getSetEntryKey(entry);
const childProxy = createProxy(entry, objectHandlers, meta!.root);
const childMeta = proxyMeta.get(childProxy)!;
childMeta.parent = receiver;
childMeta.key = synthetic;
objToProxy.set(entry, childProxy);
return childProxy;
}
if (objToProxy.has(entry)) return objToProxy.get(entry);
return entry;
};
// Pre-pass to ensure any existing non-proxied object entries are proxied (enables deep patches after iteration)
if (meta) raw.forEach(ensureEntryProxy);
if (key === "add" || key === "delete" || key === "clear") {
const fn: Function = (raw as any)[key];
return function (this: any, ...args: any[]) {
const sizeBefore = raw.size;
const result = fn.apply(raw, args);
if (raw.size !== sizeBefore) {
const metaNow = proxyMeta.get(receiver);
if (
entry &&
typeof entry === "object" &&
shouldProxy(entry) &&
!objToProxy.has(entry)
metaNow &&
metaNow.parent !== undefined &&
metaNow.key !== undefined
) {
const synthetic = getSetEntryKey(entry);
const childProxy = createProxy(entry, objectHandlers, meta!.root);
const childMeta = proxyMeta.get(childProxy)!;
childMeta.parent = receiver;
childMeta.key = synthetic;
objToProxy.set(entry, childProxy);
return childProxy;
}
if (objToProxy.has(entry)) return objToProxy.get(entry);
return entry;
};
// Pre-pass to ensure any existing non-proxied object entries are proxied
if (meta) raw.forEach(ensureEntryProxy);
if (key === "add" || key === "delete" || key === "clear") {
const fn: Function = (raw as any)[key];
return function (this: any, ...args: any[]) {
const sizeBefore = raw.size;
const result = fn.apply(raw, args);
if (raw.size !== sizeBefore) {
const metaNow = proxyMeta.get(receiver);
const containerPath = buildPath(metaNow.parent, metaNow.key);
if (key === "add") {
const entry = args[0];
let synthetic = getSetEntryKey(entry);
if (entry && typeof entry === "object") {
for (const existing of raw.values()) {
if (existing === entry) continue;
if (getSetEntryKey(existing) === synthetic) {
synthetic = assignBlankNodeId(entry);
break;
}
}
}
let entryVal = entry;
if (
metaNow &&
metaNow.parent !== undefined &&
metaNow.key !== undefined
entryVal &&
typeof entryVal === "object" &&
shouldProxy(entryVal) &&
!objToProxy.has(entryVal)
) {
const containerPath = buildPath(metaNow.parent, metaNow.key);
if (key === "add") {
const entry = args[0];
let synthetic = getSetEntryKey(entry);
if (entry && typeof entry === "object") {
for (const existing of raw.values()) {
if (existing === entry) continue;
if (getSetEntryKey(existing) === synthetic) {
synthetic = assignBlankNodeId(entry);
break;
}
const childProxy = createProxy(
entryVal,
objectHandlers,
metaNow.root
);
const childMeta = proxyMeta.get(childProxy)!;
childMeta.parent = receiver;
childMeta.key = synthetic;
objToProxy.set(entryVal, childProxy);
entryVal = childProxy;
}
// Set entry add: emit object vs literal variant.
if (entryVal && typeof entryVal === "object") {
queuePatch({
root: metaNow.root,
path: [...containerPath, synthetic],
op: "add",
type: "object",
});
} else {
queuePatch({
root: metaNow.root,
path: [...containerPath, synthetic],
op: "add",
value: entryVal,
});
}
} else if (key === "delete") {
const entry = args[0];
const synthetic = getSetEntryKey(entry);
queuePatch({
root: metaNow.root,
path: [...containerPath, synthetic],
op: "remove",
});
} else if (key === "clear") {
// Structural clear: remove prior entry-level patches for this Set this tick.
if (pendingPatches) {
const group = pendingPatches.get(metaNow.root);
if (group && group.length) {
for (let i = group.length - 1; i >= 0; i--) {
const p = group[i];
if (
p.path.length === containerPath.length + 1 &&
containerPath.every((seg, idx) => p.path[idx] === seg)
) {
group.splice(i, 1);
}
}
let entryVal = entry;
if (
entryVal &&
typeof entryVal === "object" &&
shouldProxy(entryVal) &&
!objToProxy.has(entryVal)
) {
const childProxy = createProxy(
entryVal,
objectHandlers,
metaNow.root
);
const childMeta = proxyMeta.get(childProxy)!;
childMeta.parent = receiver;
childMeta.key = synthetic;
objToProxy.set(entryVal, childProxy);
entryVal = childProxy;
}
queuePatch({
root: metaNow.root,
type: "set",
path: [...containerPath, synthetic],
value: entryVal,
});
} else if (key === "delete") {
const entry = args[0];
const synthetic = getSetEntryKey(entry);
queuePatch({
root: metaNow.root,
type: "delete",
path: [...containerPath, synthetic],
});
} else if (key === "clear") {
queuePatch({
root: metaNow.root,
type: "set",
path: containerPath,
value: raw,
});
}
}
queuePatch({
root: metaNow.root,
path: containerPath,
op: "add",
type: "set",
value: [],
});
}
return result;
};
}
}
const makeIterator = (pair: boolean) => {
return function thisIter(this: any) {
const iterable = raw.values();
return result;
};
}
const makeIterator = (pair: boolean) => {
return function thisIter(this: any) {
const iterable = raw.values();
return {
[Symbol.iterator]() {
return {
[Symbol.iterator]() {
return {
next() {
const n = iterable.next();
if (n.done) return n;
const entry = ensureEntryProxy(n.value);
return { value: pair ? [entry, entry] : entry, done: false };
},
};
next() {
const n = iterable.next();
if (n.done) return n;
const entry = ensureEntryProxy(n.value);
return { value: pair ? [entry, entry] : entry, done: false };
},
} as Iterable<any>;
};
};
if (key === "values" || key === "keys") return makeIterator(false);
if (key === "entries") return makeIterator(true);
if (key === "forEach") {
return function thisForEach(this: any, cb: any, thisArg?: any) {
raw.forEach((entry: any) => {
cb.call(
thisArg,
ensureEntryProxy(entry),
ensureEntryProxy(entry),
raw
);
});
};
}
if (key === Symbol.iterator.toString()) {
// string form access of iterator symbol; pass through
};
},
} as Iterable<any>;
};
};
if (key === "values" || key === "keys") return makeIterator(false);
if (key === "entries") return makeIterator(true);
if (key === "forEach") {
return function thisForEach(this: any, cb: any, thisArg?: any) {
raw.forEach((entry: any) => {
cb.call(thisArg, ensureEntryProxy(entry), ensureEntryProxy(entry), raw);
});
};
}
if (key === Symbol.iterator.toString()) {
// string form access of iterator symbol; pass through
}
const val = (raw as any)[key];
if (typeof val === "function") return val.bind(raw);
return val;
}
const throwOnMutation = () => {
throw new Error(
"Don't mutate the signals directly (use the underlying property/value instead)."
);
};
// Does target define a getter for key?
function hasGetter(target: any, key: any) {
return typeof descriptor(target, key)?.get === "function";
}
// Lazily allocate / fetch signal map for a proxy receiver.
function getSignals(receiver: object) {
if (!proxyToSignals.has(receiver)) proxyToSignals.set(receiver, new Map());
return proxyToSignals.get(receiver)!;
}
// Wrap & link child object/array/Set if needed.
function ensureChildProxy(value: any, parent: object, key: string | number) {
if (!shouldProxy(value)) return value;
if (!objToProxy.has(value)) {
const parentMeta = proxyMeta.get(parent)!;
const childProxy = createProxy(value, objectHandlers, parentMeta.root);
const childMeta = proxyMeta.get(childProxy)!;
childMeta.parent = parent;
childMeta.key = key as string;
objToProxy.set(value, childProxy);
}
return objToProxy.get(value);
}
// Normalize raw property key (handles $-prefix & array meta) -> { key, returnSignal }
function normalizeKey(
target: any,
fullKey: string,
isArrayMeta: boolean,
receiver: object
) {
let returnSignal = isArrayMeta || fullKey[0] === "$";
if (!isArrayMeta && Array.isArray(target) && returnSignal) {
if (fullKey === "$") {
// Provide $ meta proxy for array index signals
if (!arrayToArrayOfSignals.has(target)) {
arrayToArrayOfSignals.set(
target,
createProxy(target, arrayHandlers, proxyMeta.get(receiver)?.root)
);
}
const val = Reflect.get(raw, key, raw);
if (typeof val === "function") return val.bind(raw);
return val;
return { shortCircuit: arrayToArrayOfSignals.get(target) };
}
if (!isArrayOfSignals && returnSignal && Array.isArray(target)) {
if (fullKey === "$") {
if (!arrayToArrayOfSignals.has(target))
arrayToArrayOfSignals.set(
target,
createProxy(
target,
arrayHandlers,
proxyMeta.get(receiver)?.root // propagate root id to $ array proxy
)
);
return arrayToArrayOfSignals.get(target);
}
returnSignal = fullKey === "$length";
returnSignal = fullKey === "$length";
}
const key = returnSignal ? fullKey.replace(rg, "") : fullKey;
return { key, returnSignal } as any;
}
// Create computed signal for getter property if needed.
function ensureComputed(
signals: Map<any, any>,
target: any,
key: any,
receiver: any
) {
if (!signals.has(key) && hasGetter(target, key)) {
signals.set(
key,
computed(() => Reflect.get(target, key, receiver))
);
}
}
// Unified get trap factory (object / array meta variant)
const get =
(isArrayMeta: boolean) =>
(target: object, fullKey: string, receiver: object): unknown => {
if (peeking) return Reflect.get(target, fullKey, receiver);
// Set handling delegated completely.
if (target instanceof Set && typeof fullKey === "string") {
return getFromSet(target as Set<any>, fullKey, receiver);
}
if (!proxyToSignals.has(receiver)) proxyToSignals.set(receiver, new Map()); // allocate map lazily
const signals = proxyToSignals.get(receiver);
const key = returnSignal ? fullKey.replace(rg, "") : fullKey;
if (
!signals.has(key) &&
typeof descriptor(target, key)?.get === "function"
) {
signals.set(
key,
computed(() => Reflect.get(target, key, receiver))
);
} else {
const norm = normalizeKey(target, fullKey, isArrayMeta, receiver);
if ((norm as any).shortCircuit) return (norm as any).shortCircuit; // returned meta proxy
const { key, returnSignal } = norm as {
key: string;
returnSignal: boolean;
};
// Symbol fast-path
if (typeof key === "symbol" && wellKnownSymbols.has(key))
return Reflect.get(target, key, receiver);
const signals = getSignals(receiver);
ensureComputed(signals, target, key, receiver);
if (!signals.has(key)) {
let value = Reflect.get(target, key, receiver);
if (returnSignal && typeof value === "function") return; // functions never wrapped as signals
if (typeof key === "symbol" && wellKnownSymbols.has(key)) return value;
if (!signals.has(key)) {
if (shouldProxy(value)) {
if (!objToProxy.has(value)) {
// Child object/array lazily wrapped: link to parent for path reconstruction.
const parentMeta = proxyMeta.get(receiver)!;
const childProxy = createProxy(
value,
objectHandlers,
parentMeta.root
);
const childMeta = proxyMeta.get(childProxy)!;
childMeta.parent = receiver;
childMeta.key = key as string;
objToProxy.set(value, childProxy);
}
value = objToProxy.get(value);
}
signals.set(key, signal(value));
}
if (returnSignal && typeof value === "function") return; // user asked for signal wrapper of function => ignore
value = ensureChildProxy(value, receiver, key);
signals.set(key, signal(value));
}
// deep getter: function signals are callable; non-signal access returns current value.
// We intentionally return the raw function (signal) when `$`-prefixed so callers can set `.value` or invoke.
const sig = signals.get(key);
return returnSignal ? sig : sig();
};
// Handlers for standard object/array (non `$` array meta proxy)
// Standard object / array handlers
const objectHandlers = {
get: get(false),
set(target: object, fullKey: string, val: any, receiver: object): boolean {
@ -683,12 +669,22 @@ const objectHandlers = {
// Emit patch (after mutation) so subscribers get final value snapshot.
const meta = proxyMeta.get(receiver);
if (meta) {
queuePatch({
root: meta.root,
type: "set",
path: buildPath(receiver, fullKey),
value: val,
});
// Object/Array/Set assignment at property path.
if (val && typeof val === "object") {
queuePatch({
root: meta.root,
path: buildPath(receiver, fullKey),
op: "add",
type: "object",
});
} else {
queuePatch({
root: meta.root,
path: buildPath(receiver, fullKey),
op: "add",
value: val,
});
}
}
return result;
}
@ -705,8 +701,8 @@ const objectHandlers = {
if (meta) {
queuePatch({
root: meta.root,
type: "delete",
path: buildPath(receiverProxy, key),
op: "remove",
});
}
return result;
@ -718,7 +714,7 @@ const objectHandlers = {
},
};
// Handlers for special `$` proxy wrapping an array (index signals only)
// Array `$` meta proxy handlers (index signals only)
const arrayHandlers = {
get: get(true),
set: throwOnMutation,
@ -730,20 +726,14 @@ const wellKnownSymbols = new Set(
.map((key) => Symbol[key as WellKnownSymbols])
.filter((value) => typeof value === "symbol")
);
// Support Set so structural mutations can emit patches (Map still unsupported for now)
// Supported constructors (Map intentionally excluded for now)
const supported = new Set([Object, Array, Set]);
const shouldProxy = (val: any): boolean => {
if (typeof val !== "object" || val === null) return false;
return supported.has(val.constructor) && !ignore.has(val);
};
/** TYPES **/
/**
* Structural deep reactive view of an input type. Functions and values marked with {@link Shallow}
* are passed through untouched; arrays and plain objects become recursively deep-signal aware;
* Sets are proxied so structural & deep entry mutations emit patches.
*/
/** TYPES **/ // Structural deep reactive view of an input type.
export type DeepSignal<T> = T extends Function
? T
: T extends { [shallowFlag]: true }
@ -775,17 +765,17 @@ type DeepSignalArray<T> = DeepArray<ArrayType<T>> & {
/** Marker utility type for objects passed through without deep proxying. */
export type Shallow<T extends object> = T & { [shallowFlag]: true };
/** Framework adapter hook (declared for consumers) that returns a {@link DeepSignal} proxy. */
/** Framework adapter hook returning a DeepSignal proxy. */
export declare const useDeepSignal: <T extends object>(obj: T) => DeepSignal<T>;
// @ts-ignore
/** Utility: strip `$`-prefixed synthetic signal accessors from key union. */
// Strip `$`-prefixed synthetic signal accessors from key union.
type FilterSignals<K> = K extends `$${string}` ? never : K;
/** Reverse of {@link DeepSignalObject}: remove signal accessors to obtain original object shape. */
/** Reverse of DeepSignalObject: remove signal accessors to obtain original object shape. */
type RevertDeepSignalObject<T> = Pick<T, FilterSignals<keyof T>>;
/** Reverse of {@link DeepSignalArray}: omit meta accessors. */
/** Reverse of DeepSignalArray: omit meta accessors. */
type RevertDeepSignalArray<T> = Omit<T, "$" | "$length">;
/** Inverse mapped type that removes deepSignal wrapper affordances (`$` accessors). */
/** Inverse mapped type removing deepSignal wrapper affordances. */
export type RevertDeepSignal<T> = T extends Array<unknown>
? RevertDeepSignalArray<T>
: T extends object

@ -167,13 +167,13 @@ describe("deepsignal/core", () => {
it("should return signals from array iterators", () => {
const store = deepSignal([{ a: 1 }, { a: 2 }]);
const signals = store.map((item) => item.$a!.value);
const signals = store.map((item: any) => item.$a!.value);
expect(signals).to.deep.equal([1, 2]);
});
it("should return signals from array iterators", () => {
const store = deepSignal([{ a: 1 }, { a: 2 }]);
const signals = store.map((item) => item.$a!.value);
const signals = store.map((item: any) => item.$a!.value);
expect(signals).to.deep.equal([1, 2]);
});
});

@ -1,13 +1,13 @@
import { describe, it, expect, beforeEach } from "vitest";
import { deepSignal } from "../deepSignal";
import { watch, __traverseCount, __resetTraverseCount } from "../watch";
import { watch } from "../watch";
// Goal: demonstrate that patchOptimized deep watch performs fewer traversals
// than standard deep watch for the same batch of nested mutations.
// We use the exported __traverseCount instrumentation to measure how many
// times traverse() executes under each strategy.
describe("watch patchOptimized performance", () => {
describe("watch patch-only simplified performance placeholder", () => {
let store: any;
const build = (breadth = 3, depth = 3) => {
const make = (d: number): any => {
@ -34,43 +34,14 @@ describe("watch patchOptimized performance", () => {
visit(store, depth);
}
it("reduces traverse calls for deep watchers", async () => {
// Non-optimized deep watch
__resetTraverseCount();
const stop1 = watch(
store,
() => {
/* no-op */
},
{ deep: true, patchOptimized: false }
);
it("receives a single batch of patches after deep mutations", async () => {
let batches = 0;
const { stopListening: stop } = watch(store, ({ patches }) => {
if (patches.length) batches++;
});
mutateAll();
await Promise.resolve();
await Promise.resolve();
const traversalsNormal = __traverseCount;
stop1();
// Optimized deep watch
__resetTraverseCount();
const stop2 = watch(
store,
() => {
/* no-op */
},
{ deep: true, patchOptimized: true }
);
mutateAll();
await Promise.resolve();
await Promise.resolve();
const traversalsOptimized = __traverseCount;
stop2();
console.log(
`Traversals normal: ${traversalsNormal}, optimized: ${traversalsOptimized}`
);
// Optimized path should not perform more traversals than baseline and ideally fewer.
expect(traversalsOptimized <= traversalsNormal).toBe(true);
// Ensure baseline actually did at least one traversal more than optimized (sanity check)
expect(traversalsNormal > traversalsOptimized).toBe(true);
expect(batches).toBe(1);
stop();
});
});

@ -2,68 +2,30 @@ import { describe, it, expect } from "vitest";
import { deepSignal, getDeepSignalRootId, peek } from "../deepSignal";
import {
watch,
watchPatches,
observe,
__traverseCount,
__resetTraverseCount,
traverse,
} from "../watch";
import { effect, signal } from "../core";
import { effect } from "../core";
describe("watch advanced", () => {
it("numeric deep depth limits traversal reactions", async () => {
it("basic patch watcher fires on deep mutations", async () => {
const st = deepSignal({ a: { b: { c: 1 } } });
let runs = 0;
watch(
st,
() => {
runs++;
},
{ deep: 1 }
); // depth 1: should collect a & a.b but not a.b.c
// initial run (immediate not set so first collection sets oldValue only)
st.a.b.c = 2; // depth 2 change (may trigger depending on traversal semantics)
st.a.b = { c: 3 }; // depth 1 mutation
await Promise.resolve();
let batches: number = 0;
watch(st, ({ patches }) => {
if (patches.length) batches++;
});
st.a.b.c = 2;
st.a.b = { c: 3 } as any;
await Promise.resolve();
expect(runs).toBeGreaterThan(0);
expect(batches).toBeGreaterThan(0);
});
it("multi-source watch array triggers when one source changes", async () => {
const a = signal(1);
const b = deepSignal({ x: 1 });
let vals: any[] = [];
watch(
[a, b],
(nv) => {
vals = nv;
},
{ deep: true }
);
b.x = 2;
await Promise.resolve();
await Promise.resolve();
expect(vals[1].x).toBe(2);
});
// multi-source value mode removed; patch-only now - skip equivalent
it("watch getter source (function) with callback", async () => {
const st = deepSignal({ n: 1 });
let seen = 0;
watch(
() => st.n * 2,
(val) => {
seen = val;
},
{ immediate: true }
);
expect(seen).toBe(2);
st.n = 2;
await Promise.resolve();
await Promise.resolve();
expect(seen).toBe(4);
});
// getter source value mode removed in patch-only watcher
it("watch once with patchOptimized deep on deepSignal", async () => {
it("watch once option still stops after first batch", async () => {
const st = deepSignal({ a: 1 });
let count = 0;
watch(
@ -71,31 +33,15 @@ describe("watch advanced", () => {
() => {
count++;
},
{ deep: true, once: true, patchOptimized: true }
{ once: true, immediate: true }
);
st.a = 2;
st.a = 3;
await Promise.resolve();
await Promise.resolve();
expect(count).toBe(1);
});
it("observe value mode returns values and not patches", async () => {
const st = deepSignal({ a: 1 });
let latest: any;
const stop = observe(
st,
(v: any) => {
latest = v;
},
{ deep: true }
);
st.a = 2;
await Promise.resolve();
await Promise.resolve();
expect(latest.a).toBe(2);
stop();
});
// observe value mode removed; observe is alias of watch
});
describe("patches & root ids", () => {
@ -105,15 +51,18 @@ describe("patches & root ids", () => {
expect(getDeepSignalRootId(a)).not.toBe(getDeepSignalRootId(b));
});
it("watchPatches throws on non-deepSignal input", () => {
expect(() => watchPatches({}, () => {})).toThrow();
// legacy watchPatches API removed; patch mode only valid for deepSignal roots
it("watch throws on non-deepSignal input", () => {
expect(() => watch({} as any, () => {})).toThrow();
});
it("Map unsupported does not emit patches", async () => {
const m = new Map<string, number>();
const st = deepSignal({ m });
const patches: any[] = [];
const stop = watchPatches(st, (p) => patches.push(p));
const { stopListening: stop } = watch(st, ({ patches: batch }) =>
patches.push(batch)
);
m.set("a", 1);
await Promise.resolve();
await Promise.resolve();
@ -127,8 +76,8 @@ describe("tier3: Set iteration variants", () => {
const st = deepSignal({ s: new Set<any>() });
st.s.add({ id: "eEnt", inner: { v: 1 } });
const paths: string[] = [];
const stop = watchPatches(st, (p) =>
paths.push(...p.map((pp) => pp.path.join(".")))
const { stopListening: stop } = watch(st, ({ patches }) =>
paths.push(...patches.map((pp: any) => pp.path.join(".")))
);
for (const [val] of st.s.entries()) {
(val as any).inner.v;
@ -145,7 +94,7 @@ describe("tier3: Set iteration variants", () => {
it("forEach iteration proxies nested mutation", async () => {
const st = deepSignal({ s: new Set<any>() });
st.s.add({ id: "fe1", data: { n: 1 } });
const stop = watchPatches(st, () => {});
const { stopListening: stop } = watch(st, () => {});
st.s.forEach((e) => (e as any).data.n); // access
st.s.forEach((e) => {
(e as any).data.n = 2;
@ -158,7 +107,7 @@ describe("tier3: Set iteration variants", () => {
it("keys() iteration returns proxies", async () => {
const st = deepSignal({ s: new Set<any>() });
st.s.add({ id: "k1", foo: { x: 1 } });
const stop = watchPatches(st, () => {});
const { stopListening: stop } = watch(st, () => {});
for (const e of st.s.keys()) {
(e as any).foo.x = 2;
}

@ -13,13 +13,10 @@ describe("watch", () => {
let val!: string;
watch(
store,
(newValue) => {
({ newValue }) => {
val = newValue.userinfo.name;
},
{
immediate: true,
deep: true,
}
{ immediate: true }
);
expect(val).toEqual("tom");
});
@ -32,27 +29,27 @@ describe("watch", () => {
let val!: string;
watch(
store,
(newValue) => {
({ newValue }) => {
val = newValue.userinfo.name;
},
{
immediate: true,
deep: true,
}
{ immediate: true }
);
let value2!: string;
watch(
store,
(newValue) => {
({ newValue }) => {
value2 = newValue.userinfo.name;
},
{ immediate: true }
);
expect(val).toEqual("tom");
store.userinfo.name = "jon";
expect(val).toEqual("jon");
// With refactored watch using native effect, shallow watcher now also updates root reference
expect(value2).toEqual("jon");
// patch delivery async (microtask)
return Promise.resolve().then(() => {
expect(val).toEqual("jon");
// With refactored watch using native effect, shallow watcher now also updates root reference
expect(value2).toEqual("jon");
});
});
it("watch once", () => {
@ -64,14 +61,10 @@ describe("watch", () => {
let val!: string;
watch(
store,
(newValue) => {
({ newValue }) => {
val = newValue.userinfo.name;
},
{
immediate: true,
deep: true,
once: true,
}
{ immediate: true, once: true }
);
expect(val).toEqual("tom");

@ -1,46 +1,31 @@
import { describe, it, expect } from "vitest";
import { deepSignal, setSetEntrySyntheticId, addWithId } from "../deepSignal";
import { watchPatches, observe, Patch } from "../watch";
import {
deepSignal,
setSetEntrySyntheticId,
addWithId,
DeepPatch,
} from "../deepSignal";
import { watch, observe } from "../watch";
/**
* Tests for watchPatches / observe(..., {mode:'patch'}) ensuring:
* 1. Only patches from the provided root are emitted.
* 2. Batching groups multiple sync mutations in one array.
* 3. Delete operations are reported without value.
* 4. observe patch mode mirrors watchPatches output.
*/
// NOTE about Set entry tests:
// deepSignal does NOT rewrite external references passed into Set.add().
// After an object is added, mutate ONLY the proxied version (obtained via iteration, values(), entries(), forEach, or addWithId) to get deep patches.
// Mutating the original variable captured before adding will NOT emit patches.
describe("watchPatches", () => {
describe("watch (patch mode)", () => {
it("emits set patches with correct paths and batching", async () => {
const state = deepSignal({ a: { b: 1 }, arr: [1, { x: 2 }] });
const received: any[] = [];
const stop = watchPatches(state, (patches) => {
const received: DeepPatch[][] = [];
const { stopListening: stop } = watch(state, ({ patches }) => {
received.push(patches);
});
// multiple synchronous mutations => single microtask batch expected
state.a.b = 2;
(state.arr[1] as any).x = 3;
state.arr.push(5);
await Promise.resolve(); // flush microtask
await Promise.resolve();
expect(received.length).toBe(1);
const batch = received[0];
// Paths should reflect root-relative keys
const paths = (batch as Patch[]).map((p: Patch) => p.path.join(".")).sort();
const paths = batch.map((p) => p.path.join(".")).sort();
expect(paths).toContain("a.b");
expect(paths).toContain("arr.1.x");
expect(paths).toContain("arr.2"); // new index push
// Types
const setTypes = (batch as Patch[]).filter(
(p: Patch) => p.type === "set"
).length;
expect(setTypes).toBe(batch.length);
expect(paths).toContain("arr.2");
const addOps = batch.filter((p) => p.op === "add").length;
expect(addOps).toBe(batch.length);
stop();
});
@ -49,37 +34,37 @@ describe("watchPatches", () => {
a: { b: 1 },
c: 2,
});
const out: any[] = [];
const stop = watchPatches(state, (p) => out.push(p));
const out: DeepPatch[][] = [];
const { stopListening: stop } = watch(state, ({ patches }) =>
out.push(patches)
);
delete state.a.b;
delete state.c;
await Promise.resolve();
expect(out.length).toBe(1);
const [batch] = out;
const deletePatches = (batch as Patch[]).filter(
(p: Patch) => p.type === "delete"
);
const delPaths = deletePatches.map((p: Patch) => p.path.join(".")).sort();
const deletePatches = batch.filter((p) => p.op === "remove");
const delPaths = deletePatches.map((p) => p.path.join(".")).sort();
expect(delPaths).toEqual(["a.b", "c"]);
deletePatches.forEach((p: Patch) => expect(p.value).toBeUndefined());
deletePatches.forEach((p: any) => expect(p.value).toBeUndefined());
stop();
});
it("observe patch mode mirrors watchPatches", async () => {
it("observe patch mode mirrors watch patch mode", async () => {
const state = deepSignal({ a: 1 });
const wp: any[] = [];
const ob: any[] = [];
const stop1 = watchPatches(state, (p) => wp.push(p));
const stop2 = observe(state, (p: Patch[]) => ob.push(p), { mode: "patch" });
const wp: DeepPatch[][] = [];
const ob: DeepPatch[][] = [];
const { stopListening: stop1 } = watch(state, ({ patches }) =>
wp.push(patches)
);
const { stopListening: stop2 } = observe(state, ({ patches }) =>
ob.push(patches)
);
state.a = 2;
await Promise.resolve();
expect(wp.length).toBe(1);
expect(ob.length).toBe(1);
expect(wp[0].length).toBe(1);
expect(ob[0][0].path.join(".")).toBe("a");
expect(wp[0][0].path.join(".")).toBe("a");
stop1();
stop2();
});
@ -87,10 +72,12 @@ describe("watchPatches", () => {
it("filters out patches from other roots", async () => {
const a = deepSignal({ x: 1 });
const b = deepSignal({ y: 2 });
const out: any[] = [];
const stop = watchPatches(a, (p) => out.push(p));
b.y = 3; // unrelated root
a.x = 2; // related root
const out: DeepPatch[][] = [];
const { stopListening: stop } = watch(a, ({ patches }) =>
out.push(patches)
);
b.y = 3;
a.x = 2;
await Promise.resolve();
expect(out.length).toBe(1);
expect(out[0][0].path.join(".")).toBe("x");
@ -99,47 +86,47 @@ describe("watchPatches", () => {
it("emits patches for Set structural mutations (add/delete)", async () => {
const state = deepSignal<{ s: Set<number> }>({ s: new Set([1, 2]) });
const batches: Patch[][] = [];
const stop = watchPatches(state, (p) => batches.push(p));
const batches: DeepPatch[][] = [];
const { stopListening: stop } = watch(state, ({ patches }) =>
batches.push(patches)
);
state.s.add(3);
state.s.delete(1);
await Promise.resolve();
expect(batches.length >= 1).toBe(true);
const allPaths = batches.flatMap((b) => b.map((p) => p.path.join(".")));
// Expect per-entry paths like s.3 (primitive) or s.<synthetic>
expect(allPaths.some((p) => p.startsWith("s."))).toBe(true);
stop();
});
it("emits patches for nested objects added after initialization", async () => {
const state = deepSignal<{ root: any }>({ root: {} });
const patches: Patch[][] = [];
const stop = watchPatches(state, (p) => patches.push(p));
const patches: DeepPatch[][] = [];
const { stopListening: stop } = watch(state, ({ patches: batch }) =>
patches.push(batch)
);
state.root.child = { level: { value: 1 } };
state.root.child.level.value = 2;
await Promise.resolve();
const flat = patches.flat();
const paths = flat.map((p) => p.path.join("."));
expect(paths).toContain("root.child"); // initial add
expect(paths).toContain("root.child.level.value"); // deep mutation
const flat = patches.flat().map((p) => p.path.join("."));
expect(flat).toContain("root.child");
expect(flat).toContain("root.child.level.value");
stop();
});
it("emits structural patches for sets of sets (no deep inner object mutation tracking)", async () => {
it("emits structural patches for sets of sets", async () => {
const innerA = new Set<any>([{ id: "node1", x: 1 }]);
const s = new Set<any>([innerA]);
const state = deepSignal<{ graph: Set<any> }>({ graph: s });
const batches: Patch[][] = [];
const stop = watchPatches(state, (p) => batches.push(p));
// Add a new inner set
const batches: DeepPatch[][] = [];
const { stopListening: stop } = watch(state, ({ patches }) =>
batches.push(patches)
);
const innerB = new Set<any>([{ id: "node2", x: 5 }]);
state.graph.add(innerB);
// Mutate object inside innerA
([...innerA][0] as any).x = 2;
await Promise.resolve();
const flat = batches.flat();
const pathStrings = flat.map((p) => p.path.join("."));
// Expect a patch for adding innerB (graph.<syntheticId>)
const pathStrings = batches.flat().map((p) => p.path.join("."));
expect(pathStrings.some((p) => p.startsWith("graph."))).toBe(true);
stop();
});
@ -147,117 +134,124 @@ describe("watchPatches", () => {
it("tracks deep nested object mutation inside a Set entry after iteration", async () => {
const rawEntry = { id: "n1", data: { val: 1 } };
const st = deepSignal({ bag: new Set<any>([rawEntry]) });
const patches: Patch[][] = [];
const stop = watchPatches(st, (p) => patches.push(p));
// Obtain proxied entry via iteration
const collected: DeepPatch[][] = [];
const { stopListening: stop } = watch(st, ({ patches }) =>
collected.push(patches)
);
let proxied: any;
for (const e of st.bag.values()) {
proxied = e; // this is the proxied version
e.data.val; // access to ensure deep proxying of nested object
proxied = e;
e.data.val;
}
// Mutate proxied (NOT rawEntry)
proxied.data.val = 2;
await Promise.resolve();
const flat = patches.flat().map((p) => p.path.join("."));
expect(flat.some((p) => p.endsWith("n1.data.val"))).toBe(true);
const flat = collected.flat().map((p: DeepPatch) => p.path.join("."));
expect(flat.some((p: string) => p.endsWith("n1.data.val"))).toBe(true);
stop();
});
it("allows custom synthetic id for Set entry", async () => {
const node = { name: "x" };
const state = deepSignal({ s: new Set<any>() });
const patches: Patch[][] = [];
const stop = watchPatches(state, (p) => patches.push(p));
// Preferred ergonomic helper
const collected2: DeepPatch[][] = [];
const { stopListening: stop } = watch(state, ({ patches }) =>
collected2.push(patches)
);
addWithId(state.s as any, node, "custom123");
await Promise.resolve();
const flat = patches.flat().map((p) => p.path.join("."));
expect(flat.some((p) => p === "s.custom123")).toBe(true);
const flat = collected2.flat().map((p: DeepPatch) => p.path.join("."));
expect(flat.some((p: string) => p === "s.custom123")).toBe(true);
stop();
});
describe("Set", () => {
it("emits one structural patch on Set.clear()", async () => {
it("emits single structural patch on Set.clear()", async () => {
const st = deepSignal({ s: new Set<any>() });
addWithId(st.s as any, { id: "a", x: 1 }, "a");
addWithId(st.s as any, { id: "b", x: 2 }, "b");
const batches: Patch[][] = [];
const stop = watchPatches(st, (p) => batches.push(p));
const batches: DeepPatch[][] = [];
const { stopListening: stop } = watch(st, ({ patches }) =>
batches.push(patches)
);
st.s.clear();
await Promise.resolve();
const all = batches.flat().map((p) => p.path.join("."));
expect(all).toContain("s");
expect(all).toEqual(["s"]);
stop();
});
it("emits delete patch for object entry", async () => {
const st = deepSignal({ s: new Set<any>() });
const obj = { id: "n1", x: 1 };
const patches: Patch[][] = [];
const stop = watchPatches(st, (p) => patches.push(p));
const patches: DeepPatch[][] = [];
const { stopListening: stop } = watch(st, ({ patches: batch }) =>
patches.push(batch)
);
st.s.add(obj);
st.s.delete(obj);
await Promise.resolve();
const all = patches
.flat()
.filter((p) => p.type === "delete")
.filter((p) => p.op === "remove")
.map((p) => p.path.join("."));
expect(all).toContain("s.n1");
stop();
});
it("does not emit patch for duplicate add", async () => {
const st = deepSignal({ s: new Set<number>([1]) });
const patches: Patch[][] = [];
const stop = watchPatches(st, (p) => patches.push(p));
st.s.add(1); // duplicate
const patches: DeepPatch[][] = [];
const { stopListening: stop } = watch(st, ({ patches: batch }) =>
patches.push(batch)
);
st.s.add(1);
await Promise.resolve();
// no new patches (size unchanged)
expect(patches.length).toBe(0);
stop();
});
it("does not emit patch deleting non-existent entry", async () => {
const st = deepSignal({ s: new Set<number>([1]) });
const patches: Patch[][] = [];
const stop = watchPatches(st, (p) => patches.push(p));
const patches: DeepPatch[][] = [];
const { stopListening: stop } = watch(st, ({ patches: batch }) =>
patches.push(batch)
);
st.s.delete(2);
await Promise.resolve();
expect(patches.length).toBe(0);
stop();
});
it("addWithId primitive returns primitive and emits patch with primitive key", async () => {
const st = deepSignal({ s: new Set<any>() });
const patches: Patch[][] = [];
const stop = watchPatches(st, (p) => patches.push(p));
const ret = addWithId(st.s as any, 5, "ignored"); // primitives ignore id
const patches: DeepPatch[][] = [];
const { stopListening: stop } = watch(st, ({ patches: batch }) =>
patches.push(batch)
);
const ret = addWithId(st.s as any, 5, "ignored");
expect(ret).toBe(5);
await Promise.resolve();
const paths = patches.flat().map((p) => p.path.join("."));
expect(paths).toContain("s.5");
stop();
});
it("setSetEntrySyntheticId applies custom id without helper", async () => {
const st = deepSignal({ s: new Set<any>() });
const obj = { name: "x" };
setSetEntrySyntheticId(obj, "customX");
const patches: Patch[][] = [];
const stop = watchPatches(st, (p) => patches.push(p));
const patches: DeepPatch[][] = [];
const { stopListening: stop } = watch(st, ({ patches: batch }) =>
patches.push(batch)
);
st.s.add(obj);
await Promise.resolve();
const paths = patches.flat().map((p) => p.path.join("."));
expect(paths).toContain("s.customX");
stop();
});
it("values/entries/forEach proxy nested mutation", async () => {
const st = deepSignal({ s: new Set<any>() });
const entry = addWithId(st.s as any, { id: "e1", inner: { v: 1 } }, "e1");
const batches: Patch[][] = [];
const stop = watchPatches(st, (p) => batches.push(p));
// values()
const batches: DeepPatch[][] = [];
const { stopListening: stop } = watch(st, ({ patches }) =>
batches.push(patches)
);
for (const e of st.s.values()) {
e.inner.v;
}
@ -267,17 +261,17 @@ describe("watchPatches", () => {
expect(vPaths.some((p) => p.endsWith("e1.inner.v"))).toBe(true);
stop();
});
it("raw reference mutation produces no deep patch while proxied does", async () => {
const raw = { id: "id1", data: { x: 1 } };
const st = deepSignal({ s: new Set<any>([raw]) });
const batches: Patch[][] = [];
const stop = watchPatches(st, (p) => batches.push(p));
raw.data.x = 2; // mutate raw (no patch expected for deep)
const batches: DeepPatch[][] = [];
const { stopListening: stop } = watch(st, ({ patches }) =>
batches.push(patches)
);
raw.data.x = 2;
await Promise.resolve();
const afterRaw = batches.flat().map((p) => p.path.join("."));
expect(afterRaw.some((p) => p.endsWith("id1.data.x"))).toBe(false);
// Now mutate via proxied
let proxied: any;
for (const e of st.s.values()) proxied = e;
proxied.data.x = 3;
@ -286,21 +280,21 @@ describe("watchPatches", () => {
expect(afterProxied.some((p) => p.endsWith("id1.data.x"))).toBe(true);
stop();
});
it("synthetic id collision assigns unique blank node id", async () => {
const st = deepSignal({ s: new Set<any>() });
const a1 = { id: "dup", v: 1 };
const a2 = { id: "dup", v: 2 };
const patches: Patch[][] = [];
const stop = watchPatches(st, (p) => patches.push(p));
const patches: DeepPatch[][] = [];
const { stopListening: stop } = watch(st, ({ patches: batch }) =>
patches.push(batch)
);
st.s.add(a1);
st.s.add(a2);
await Promise.resolve();
const keys = patches
.flat()
.filter((p) => p.type === "set")
.filter((p) => p.op === "add")
.map((p) => p.path.slice(-1)[0]);
// Expect two distinct keys
expect(new Set(keys).size).toBe(2);
stop();
});
@ -309,21 +303,24 @@ describe("watchPatches", () => {
describe("Arrays & mixed batch", () => {
it("emits patches for splice/unshift/shift in single batch", async () => {
const st = deepSignal({ arr: [1, 2, 3] });
const batches: Patch[][] = [];
const stop = watchPatches(st, (p) => batches.push(p));
st.arr.splice(1, 1, 99, 100); // delete index1, add two
const batches: DeepPatch[][] = [];
const { stopListening: stop } = watch(st, ({ patches }) =>
batches.push(patches)
);
st.arr.splice(1, 1, 99, 100);
st.arr.unshift(0);
st.arr.shift(); // undo
st.arr.shift();
await Promise.resolve();
const paths = batches.flat().map((p) => p.path.join("."));
expect(paths.some((p) => p.startsWith("arr."))).toBe(true);
stop();
});
it("mixed object/array/Set mutations batch together", async () => {
const st = deepSignal({ o: { a: 1 }, arr: [1], s: new Set<any>() });
const batches: Patch[][] = [];
const stop = watchPatches(st, (p) => batches.push(p));
const batches: DeepPatch[][] = [];
const { stopListening: stop } = watch(st, ({ patches }) =>
batches.push(patches)
);
st.o.a = 2;
st.arr.push(2);
addWithId(st.s as any, { id: "z", v: 1 }, "z");

@ -1,334 +1,144 @@
import { effect as nativeEffect, isSignal, signal as coreSignal } from "./core";
import {
hasChanged,
isArray,
isFunction,
isMap,
isObject,
isPlainObject,
isSet,
} from "./utils";
import { isObject, isPlainObject, isSet, isMap, isArray } from "./utils";
import { isSignal } from "./core";
import {
isDeepSignal,
isShallow,
subscribeDeepMutations,
getDeepSignalRootId,
DeepPatch,
} from "./deepSignal";
import { ReactiveFlags } from "./contents";
/** Callback passed to watcher side-effects allowing registration of a cleanup function. */
export type OnCleanup = (cleanupFn: () => void) => void;
/** Signature for watchEffect style sources receiving an {@link OnCleanup}. */
export type WatchEffect = (onCleanup: OnCleanup) => void;
/** Source accepted by {@link watch}: plain value, deepSignal proxy, signal/computed, or getter. */
export type WatchSource<T = any> = any | (() => T);
/** Function provided to register a disposer (runs before next callback or on stop). */
export type RegisterCleanup = (cleanupFn: () => void) => void;
/** Signature for watchEffect style sources receiving the cleanup registrar. */
export type WatchEffect = (registerCleanup: RegisterCleanup) => void;
/** Configuration options controlling {@link watch} behavior. */
export interface WatchOptions<Immediate = boolean> {
/** Options for {@link watch}. */
export interface WatchOptions {
/** Trigger the callback immediately with the current value (default: false). */
immediate?: Immediate;
/** Deep traversal depth: true/Infinity for full, number for limited depth, 0/false for shallow. */
deep?: boolean | number;
/** Auto-stop the watcher after the first successful callback run. */
immediate?: boolean;
/** Auto-stop the watcher after the first callback run that delivers patches (or immediate call if no patches). */
once?: boolean;
/** If true (default) and deep watching a deepSignal, use version bumps from patch stream instead of traversing. */
patchOptimized?: boolean;
/** Allow legacy/unknown options (ignored) to avoid hard breaks while migrating. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[legacy: string]: any;
}
/** User callback signature for {@link watch}. */
export type WatchCallback<V = any, OV = any> = (
value: V,
oldValue: OV,
onCleanup: OnCleanup
) => any;
const INITIAL_WATCHER_VALUE = {};
/** Internal effect-like interface used for watcher lifecycle & scheduling. */
export interface WatchEffectInstance {
active: boolean;
dirty: boolean;
scheduler: (immediateFirstRun?: boolean) => void;
run: () => any;
stop: () => void;
export interface WatchPatchEvent<Root = any> {
/** Patch batch that triggered this callback (may be empty for immediate). */
patches: DeepPatch[];
/** Previous snapshot (deep-cloned) of the root value before these patches. Undefined on first call. */
oldValue: Root | undefined;
/** Current root value (live proxy). */
newValue: Root;
}
let activeWatcher!: WatchEffectInstance;
// const resetTrackingStack: (Subscriber | undefined)[] = []
// export let activeSub: Subscriber | undefined = undefined
// export function setActiveSub(sub: Subscriber | undefined): void {
// activeSub = sub
// }
// /**
// * Temporarily pauses tracking.
// */
// export function pauseTracking(): void {
// resetTrackingStack.push(activeSub)
// activeSub = undefined
// }
// /**
// * Resets the previous global effect tracking state.
// */
// export function resetTracking(): void {
// if (process.env.NODE_ENV !== 'production'
// && resetTrackingStack.length === 0) {
// console.warn(
// `resetTracking() was called when there was no active tracking ` +
// `to reset.`,
// )
// }
// if (resetTrackingStack.length) {
// activeSub = resetTrackingStack.pop()!
// } else {
// activeSub = undefined
// }
// }
export type WatchPatchCallback<Root = any> = (
event: WatchPatchEvent<Root>
) => any;
// Internal helper kept for external compatibility.
export const remove = <T>(arr: T[], el: T): void => {
const i = arr.indexOf(el);
if (i > -1) {
arr.splice(i, 1);
}
if (i > -1) arr.splice(i, 1);
};
/**
* watch()
* --------------------------------------------------------------
* Unified watcher for:
* - A single signal/computed (function signal)
* - A deepSignal proxy object (tracks nested mutations via traversal)
* - An array of the above (multi-source)
* - A getter function (with optional cleanup) ala watchEffect (cb omitted)
*
* Implementation notes:
* - We create an internal Effect wrapper which schedules `job` on dependency invalidation.
* - `job` evaluates the getter (lazily when needed) and compares new vs old value(s) unless
* forceTrigger or deep mode bypasses the shallow comparison.
* - Deep mode uses `traverse()` to touch nested properties ensuring dependency collection.
* - For watchEffect (no cb) we directly execute the source in the Effect's getter.
*/
/**
* Observe reactive sources (signal/computed/deepSignal/getter) and invoke a callback on change.
* Supports: single source, multi-source array, deep traversal, patch-optimized deepSignal watching,
* value-style (with callback) and effect-style (no callback) usage.
*/
export function watch(
source: WatchSource | WatchSource[] | WatchEffect | object,
cb?: WatchCallback,
/** Observe patch batches on a deep signal root. */
export function watch<Root = any>(
source: Root,
callback: WatchPatchCallback<Root>,
options: WatchOptions = {}
) {
const { once, immediate, deep, patchOptimized = true } = options;
let effectInstance!: WatchEffectInstance;
let getter!: () => any;
let forceTrigger = false;
let isMultiSource = false;
// let cleanup = NOOP
const signalGetter = (source: object) => {
// traverse will happen in wrapped getter below
if (deep) return source;
// for `deep: false | 0` or shallow reactive, only traverse root-level properties
if (isShallow(source) || deep === false || deep === 0)
return traverse(source, 1);
// for `deep: undefined` on a reactive object, deeply traverse all properties
return traverse(source);
};
let unsubscribePatches: (() => void) | undefined;
let skipTraverse = false; // set when patchOptimized deep watch path chosen
const watchHandle = () => {
if (unsubscribePatches) unsubscribePatches();
if (effectInstance) effectInstance.stop();
return effectInstance;
};
// once wrapping deferred until after effectInstance created
if (isSignal(source)) {
getter = () => (source as any)();
forceTrigger = isShallow(source);
} else if (isDeepSignal(source)) {
if (deep && patchOptimized) {
// Use a version signal updated per relevant patch batch.
const version = coreSignal(0);
const rootId = getDeepSignalRootId(source as any);
unsubscribePatches = subscribeDeepMutations((patches) => {
if (!effectInstance || !effectInstance.active) return;
if (patches.some((p) => p.root === rootId)) {
version(version() + 1);
}
});
getter = () => {
version();
return source;
};
forceTrigger = true;
skipTraverse = true; // we rely on patch version invalidations; no deep traversal needed
} else {
getter = () => signalGetter(source as any);
forceTrigger = true;
}
} else if (isArray(source)) {
isMultiSource = true;
forceTrigger = source.some((s) => isDeepSignal(s) || isShallow(s));
getter = () =>
source.map((s) => {
if (isSignal(s)) return (s as any)();
else if (isDeepSignal(s)) return signalGetter(s);
});
} else if (isFunction(source)) {
if (cb) {
// getter with cb
getter = source as () => any;
} else {
// no cb -> simple effect (watchEffect)
getter = () =>
(source as any)((_?: any) => {
/* ignore cleanup */
});
}
} else {
getter = () => source;
}
if (cb && deep && !skipTraverse) {
const baseGetter = getter;
const depth = deep === true ? Infinity : deep;
getter = () => traverse(baseGetter(), depth);
if (!isDeepSignal(source)) {
throw new Error(
"watch() now only supports deepSignal roots (patch mode only)"
);
}
const { immediate, once } = options;
let oldValue: any = isMultiSource
? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE)
: INITIAL_WATCHER_VALUE;
const rootId = getDeepSignalRootId(source as any)!;
const job = (immediateFirstRun?: boolean) => {
if (
!effectInstance.active ||
(!immediateFirstRun && !effectInstance.dirty)
) {
return;
let active = true;
let cleanup: (() => void) | undefined;
const registerCleanup: RegisterCleanup = (fn) => {
cleanup = fn;
};
const runCleanup = () => {
if (cleanup) {
try {
cleanup();
} catch {
/* ignore */
} finally {
cleanup = undefined;
}
}
if (cb) {
// watch(source, cb)
const newValue = effectInstance.run();
};
if (
deep ||
forceTrigger ||
(isMultiSource
? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i]))
: hasChanged(newValue, oldValue))
) {
const currentWatcher = activeWatcher;
activeWatcher = effectInstance;
try {
const args = [
newValue,
// pass undefined as the old value when it's changed for the first time
oldValue === INITIAL_WATCHER_VALUE
? undefined
: isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE
? []
: oldValue,
effectInstance.stop,
];
// @ts-ignore
cb!(...args);
oldValue = newValue;
if (once) watchHandle();
} finally {
activeWatcher = currentWatcher;
}
}
} else {
// watchEffect
effectInstance.run();
if (once) watchHandle();
// Deep clone snapshot helper (JSON clone sufficient for typical reactive plain data)
const clone = (v: any) => {
try {
return JSON.parse(JSON.stringify(v));
} catch {
return undefined as any;
}
};
let lastSnapshot: Root | undefined = clone(source);
// Create native effect and wrap into Effect-like instance
let stopNative: (() => void) | undefined;
const instance: WatchEffectInstance = {
active: true,
dirty: true,
scheduler: job,
run: () => {
instance.dirty = false;
return getter();
},
stop: () => {
if (instance.active) {
stopNative && stopNative();
instance.active = false;
}
},
const stopListening = () => {
if (!active) return;
active = false;
runCleanup();
unsubscribe && unsubscribe();
};
effectInstance = instance;
// nativeEffect returns a disposer (stop)
stopNative = nativeEffect(() => {
instance.dirty = true;
getter();
instance.scheduler();
});
if (cb) {
if (immediate) {
job(true);
} else {
oldValue = effectInstance.run();
const deliver = (patches: DeepPatch[]) => {
if (!active) return;
runCleanup();
const prev = lastSnapshot;
const next = source as any as Root; // live proxy
try {
callback({
patches,
oldValue: prev,
newValue: next,
});
} finally {
if (active) lastSnapshot = clone(next);
if (once) stopListening();
}
} else {
effectInstance.run();
}
return watchHandle;
}
};
// -----------------------------
// Patch & unified observe APIs
// -----------------------------
/** Shape of a mutation patch delivered by {@link watchPatches} / {@link observe} in patch mode. */
export interface Patch {
root: symbol;
type: "set" | "delete";
path: (string | number)[];
value?: any;
}
/**
* Filtered subscription to deep mutation patches for a specific deepSignal root.
* @throws if the provided value is not a deepSignal root instance.
*/
export function watchPatches(deepSignal: any, cb: (patches: Patch[]) => void) {
if (!isDeepSignal(deepSignal))
throw new Error("watchPatches() expects a deepSignal root");
const root = getDeepSignalRootId(deepSignal);
return subscribeDeepMutations((batch) => {
const filtered = batch.filter((p) => p.root === root);
if (filtered.length) cb(filtered);
const unsubscribe = subscribeDeepMutations(rootId, (patches) => {
if (!patches.length) return; // ignore empty batches
deliver(patches);
});
}
interface ObserveOptionsValue extends WatchOptions {
mode?: "value";
}
interface ObserveOptionsPatch {
mode: "patch";
if (immediate) {
// Immediate call with empty patch list (snapshot only)
deliver([]);
}
return {
/** Stop listening to future patch batches; idempotent. */
stopListening,
/** Register a cleanup callback run before the next invocation / stop. */
registerCleanup,
};
}
/** Options accepted by {@link observe} to select value vs patch emission mode. */
type ObserveOptions = ObserveOptionsValue | ObserveOptionsPatch;
// observe(): unifies value watching and patch watching
/** Unified API bridging {@link watch} (value mode) and {@link watchPatches} (patch mode). */
export function observe(source: any, cb: any, options: ObserveOptions = {}) {
if (options.mode === "patch") return watchPatches(source, cb);
return watch(source, cb, options as WatchOptions);
// observe alias
export function observe(
source: any,
cb: WatchPatchCallback,
options?: WatchOptions
) {
return watch(source, cb, options);
}
// Instrumentation counter for performance tests (number of traverse invocations)
/** Instrumentation counter tracking total `traverse()` invocations (used in tests). */
export let __traverseCount = 0;
export let __traverseCount = 0; // retained for external tooling/tests although watch no longer uses traversal
/** Reset the traversal instrumentation counter back to 0. */
export function __resetTraverseCount() {
__traverseCount = 0;

@ -1,12 +1,32 @@
import { watch } from "./watch";
/**
* Run a reactive effect function immediately and again whenever its accessed dependencies change.
* Provides an optional `onCleanup` registration parameter for teardown logic between re-runs.
* Equivalent to `watch(effectFn)` with no explicit callback.
*/
import { effect as coreEffect } from "./core";
/** Run a reactive function and re-run on its dependencies; supports cleanup. */
export function watchEffect(
effect: (onCleanup?: (fn: () => void) => void) => void
fn: (registerCleanup?: (cleanup: () => void) => void) => void
) {
return watch(effect as any, undefined);
let cleanup: (() => void) | undefined;
const registerCleanup = (cb: () => void) => {
cleanup = cb;
};
const stop = coreEffect(() => {
if (cleanup) {
try {
cleanup();
} catch {
/* ignore */
} finally {
cleanup = undefined;
}
}
fn(registerCleanup);
});
return () => {
if (cleanup) {
try {
cleanup();
} catch {
/* ignore */
}
}
stop();
};
}

Loading…
Cancel
Save