Fork of https://github.com/CCherry07/alien-deepsignals ported to alien-signals v2 with support for per-value modification tracking.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
Laurin Weger 400ba719b5
fix array modification
1 week ago
src fix array modification 1 week ago
.gitignore major refactor: Use alien-signals v2, support for watching patches 2 weeks ago
README.md fix array modification 1 week ago
package.json major refactor: Use alien-signals v2, support for watching patches 2 weeks ago
pnpm-lock.yaml major refactor: Use alien-signals v2, support for watching patches 2 weeks ago
tsconfig.json feat: update signals version 8 months ago
tsup.config.ts feat: update signals version 8 months ago

README.md

alien-deepsignals

Deep structural reactivity for plain objects / arrays / Sets built on top of alien-signals.

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

  • Lazy: signals & child proxies created only when touched.
  • Deep: nested objects, arrays, Sets proxied on demand.
    • TODO: Methods might not be proxied (e.g. array.push)?
  • 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.

Install

pnpm add alien-deepsignals
# or
npm i alien-deepsignals

Quick start

import { deepSignal } from 'alien-deepsignals'

const state = deepSignal({
  count: 0,
  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

Watching patches

watch(root, cb, options?) observes a deepSignal root and invokes your callback with microtask‑batched mutation patches plus snapshots.

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

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

Option Type Default Description
immediate boolean false Fire once right away with patches: [].
once boolean false Auto stop after first callback (immediate counts).

observe() is an alias of watch().

DeepPatch format

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:

import { addWithId, setSetEntrySyntheticId } from 'alien-deepsignals'

setSetEntrySyntheticId(obj, 'custom')
state.settings.add(obj)
addWithId(state.settings as any, { x:1 }, 'x1')

Shallow

Skip deep proxying of a subtree (only reference replacement tracked):

import { shallow } from 'alien-deepsignals'
state.config = shallow({ huge: { blob: true } })

TypeScript ergonomics

DeepSignal<T> exposes both plain properties and optional $prop signal accessors (excluded for function members). Arrays add $ (index signal map) and $length.

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

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 – thanks to @luisherranz. Re-imagined with patch batching & Set support.

License

MIT