parent
58e5e96fa3
commit
68f9ec498c
@ -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. |
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()`. |
||||||
- 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). |
|
||||||
|
|
||||||
## Features |
## 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. |
* Lazy: signals & child proxies created only when touched. |
||||||
- **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`. |
* Deep: nested objects, arrays, Sets proxied on demand. |
||||||
- **Full array support**: `deepsignal` fully supports arrays, including nested arrays. |
* Per-property signals: fine‑grained invalidation without traversal on each change. |
||||||
- **Deep**: `deepsignal` converts nested objects and arrays to deep signal objects/arrays, allowing you to create fully reactive data structures. |
* Patch stream: microtask‑batched granular mutations (paths + op) for syncing external stores / framework adapters. |
||||||
- **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. |
* Getter => computed: property getters become derived (readonly) signals automatically. |
||||||
- **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. |
* `$` accessors: TypeScript exposes `$prop` for each non‑function key plus `$` / `$length` for arrays. |
||||||
- **Automatic derived state**: getters are automatically converted to computeds instead of signals. |
* Sets: structural `add/delete/clear` emit patches; object entries get synthetic stable ids (prefers `id` / `@id` fields or auto‑generated blank IDs). |
||||||
- **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`. |
* Shallow escape hatch: wrap sub-objects with `shallow(obj)` to track only reference replacement. |
||||||
- **State management**: `deepsignal` can be used as a state manager, including state and actions in the same object. |
|
||||||
|
|
||||||
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. |
## Install |
||||||
|
|
||||||
## Installation |
|
||||||
|
|
||||||
```bash |
```bash |
||||||
npm install alien-deepsignals |
pnpm add alien-deepsignals |
||||||
|
# or |
||||||
|
npm i alien-deepsignals |
||||||
``` |
``` |
||||||
|
|
||||||
## Usage |
## Quick start |
||||||
|
|
||||||
```ts |
```ts |
||||||
import { deepSignal } from 'alien-deepsignals'; |
import { deepSignal } from 'alien-deepsignals' |
||||||
|
|
||||||
const state = deepSignal({ |
const state = deepSignal({ |
||||||
count: 0, |
count: 0, |
||||||
name: 'John', |
user: { name: 'Ada' }, |
||||||
nested: { |
items: [{ id: 'i1', qty: 1 }], |
||||||
deep: 'value', |
settings: new Set(['dark']) |
||||||
}, |
}) |
||||||
array: [1, 2, 3], |
|
||||||
}); |
state.count++ // mutate normally |
||||||
state.count++; |
state.user.name = 'Grace' // nested write |
||||||
state.$nested.value.deep = 'new value'; |
state.items.push({ id: 'i2', qty: 2 }) |
||||||
state.$array.value.push(4); |
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 |
```ts |
||||||
import { deepSignal, watch } from 'alien-deepsignals'; |
import { watch } from 'alien-deepsignals' |
||||||
const state = deepSignal({ |
|
||||||
count: 0, |
const stop = watch(state, ({ patches, oldValue, newValue }) => { |
||||||
name: 'John', |
for (const p of patches) { |
||||||
nested: { |
console.log(p.op, p.path.join('.'), 'value' in p ? p.value : p.type) |
||||||
deep: 'value', |
} |
||||||
}, |
|
||||||
array: [1, 2, 3], |
|
||||||
}); |
|
||||||
|
|
||||||
watch(state,(value)=>{ |
|
||||||
console.log(value); |
|
||||||
},{ |
|
||||||
deep: true, |
|
||||||
immediate: true, |
|
||||||
// once |
|
||||||
}) |
}) |
||||||
|
|
||||||
|
state.user.name = 'Lin' |
||||||
|
state.items[0].qty = 3 |
||||||
|
await Promise.resolve() // flush microtask |
||||||
|
stop() |
||||||
``` |
``` |
||||||
|
|
||||||
### Advanced watching & patch stream |
### Callback event shape |
||||||
|
|
||||||
There are two layers: |
```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 |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
### Options |
||||||
|
|
||||||
1. `watch()` (high-level) – subscribe to value changes of signals, deepSignal objects, getters, or arrays of them. Supports options: |
| Option | Type | Default | Description | |
||||||
- `immediate` (fire once right away) |
|--------|------|---------|-------------| |
||||||
- `deep` (traverse nested properties to collect deps) |
| `immediate` | boolean | false | Fire once right away with `patches: []`. | |
||||||
- `once` (auto-dispose after first emission) |
| `once` | boolean | false | Auto stop after first callback (immediate counts). | |
||||||
- `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. |
|
||||||
|
|
||||||
#### patchOptimized |
`observe()` is an alias of `watch()`. |
||||||
|
|
||||||
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. |
## DeepPatch format |
||||||
|
|
||||||
```ts |
```ts |
||||||
watch(state, (val) => { |
type DeepPatch = { |
||||||
render(val) |
root: symbol // stable id per deepSignal root |
||||||
}, { deep: true, patchOptimized: true }) |
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) |
||||||
|
) |
||||||
``` |
``` |
||||||
|
|
||||||
### New helper APIs (optional sugar) |
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 |
```ts |
||||||
import { watchPatches, observe } from 'alien-deepsignals' |
import { addWithId, setSetEntrySyntheticId } from 'alien-deepsignals' |
||||||
|
|
||||||
// 1. watchPatches: directly receive deep mutation patch batches for a deepSignal root |
setSetEntrySyntheticId(obj, 'custom') |
||||||
const stop = watchPatches(state, (patches) => { |
state.settings.add(obj) |
||||||
for (const p of patches) { |
addWithId(state.settings as any, { x:1 }, 'x1') |
||||||
console.log(p.type, p.path.join('.'), p.value) |
``` |
||||||
} |
|
||||||
}) |
## Shallow |
||||||
|
|
||||||
// 2. observe: unified API for value or patch modes |
Skip deep proxying of a subtree (only reference replacement tracked): |
||||||
// value mode (essentially watch()) |
```ts |
||||||
const offValue = observe(state, (val, old) => { |
import { shallow } from 'alien-deepsignals' |
||||||
console.log('value changed', old, '=>', val) |
state.config = shallow({ huge: { blob: true } }) |
||||||
}, { mode: 'value', deep: true, patchOptimized: true }) |
``` |
||||||
|
|
||||||
|
## TypeScript ergonomics |
||||||
|
|
||||||
// patch mode (delegates to watchPatches) |
`DeepSignal<T>` exposes both plain properties and optional `$prop` signal accessors (excluded for function members). Arrays add `$` (index signal map) and `$length`. |
||||||
const offPatches = observe(state, (patches) => { |
|
||||||
console.log('patch batch', patches) |
```ts |
||||||
}, { mode: 'patch' }) |
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 surface |
||||||
| API | Emits | Use-case | |
|
||||||
|-----|-------|----------| |
| Function | Description | |
||||||
| `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 | |
| `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,12 +1,32 @@ |
|||||||
import { watch } from "./watch"; |
import { effect as coreEffect } from "./core"; |
||||||
|
/** Run a reactive function and re-run on its dependencies; supports cleanup. */ |
||||||
/** |
|
||||||
* 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. |
|
||||||
*/ |
|
||||||
export function watchEffect( |
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…
Reference in new issue