@pyreon/reactivity 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +141 -36
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # @pyreon/reactivity
2
2
 
3
- Signal-based fine-grained reactivity primitives for the Pyreon framework.
3
+ Standalone fine-grained reactivity primitives signals, computeds, effects, stores, resources, scopes.
4
+
5
+ `@pyreon/reactivity` is the foundation layer every other Pyreon package builds on, but it has zero framework dependencies and works on its own in Node, Bun, edge workers, or any JavaScript environment without DOM or JSX. Subscribers are tracked via a `Set<() => void>`; batches use a pointer swap for zero-allocation grouping. Two-tier batch flush (computed recompute → effect run) prevents stale reads in diamond-shaped dependency graphs.
4
6
 
5
7
  ## Install
6
8
 
@@ -8,70 +10,173 @@ Signal-based fine-grained reactivity primitives for the Pyreon framework.
8
10
  bun add @pyreon/reactivity
9
11
  ```
10
12
 
11
- ## Quick Start
13
+ ## Quick start
12
14
 
13
15
  ```ts
14
- import { signal, computed, effect, batch } from '@pyreon/reactivity'
16
+ import {
17
+ signal, computed, effect, batch, onCleanup, watch, untrack,
18
+ createStore, createResource, effectScope,
19
+ } from '@pyreon/reactivity'
15
20
 
16
21
  const count = signal(0)
17
22
  const doubled = computed(() => count() * 2)
18
23
 
19
- effect(() => {
24
+ const dispose = effect(() => {
20
25
  console.log('doubled:', doubled())
26
+ onCleanup(() => console.log('cleaning up'))
21
27
  })
22
28
 
29
+ batch(() => {
30
+ count.set(1)
31
+ count.set(2) // subscribers fire once, with doubled = 4
32
+ })
33
+
34
+ watch(() => count(), (next, prev) => console.log(`${prev} → ${next}`))
35
+
36
+ const store = createStore({ todos: [{ text: 'Learn Pyreon', done: false }] })
37
+ store.todos[0].done = true // fine-grained update, no immer
38
+
39
+ dispose()
40
+ ```
41
+
42
+ ## The signal contract
43
+
44
+ ```ts
45
+ const x = signal(0)
46
+ x() // read (subscribes if inside a tracked scope)
47
+ x.set(1) // write
48
+ x.update(n => n + 1)
49
+ x.peek() // read without subscribing
50
+ ```
51
+
52
+ Signals are **callable functions**, not `.value` getters (Vue) and not `[state, setState]` tuples (React). Calling the signal as a function is the read; `signal(5)` does NOT set the value — it reads and discards the argument. Dev mode warns; the `@pyreon/lint` rule `signal-write-as-call` flags it statically.
53
+
54
+ Optional `name` for debugging: `signal(0, { name: 'count' })` — the `@pyreon/vite-plugin` injects names automatically in dev.
55
+
56
+ ## Computed
57
+
58
+ ```ts
59
+ const doubled = computed(() => count() * 2)
60
+ const sameRef = computed(() => obj(), { equals: (a, b) => a.id === b.id })
61
+ ```
62
+
63
+ Lazy, memoized, auto-tracking. Recomputes only when a dependency changes AND a subscriber actually reads it. Pass a custom `equals` to dedupe by structural identity instead of `Object.is`.
64
+
65
+ ## Effects
66
+
67
+ ```ts
68
+ const dispose = effect(() => {
69
+ console.log(count())
70
+ onCleanup(() => console.log('before next run / on dispose'))
71
+ })
72
+ dispose()
73
+ ```
74
+
75
+ `effect()` re-runs on tracked-signal change; the returned function disposes. Returning a cleanup function from the effect body is supported; `onCleanup(fn)` is the explicit form. `renderEffect()` is a lighter DOM-targeted variant that does NOT support `onCleanup` and does NOT register with `EffectScope` — used internally by `@pyreon/runtime-dom`.
76
+
77
+ `watch(source, callback)` is the explicit-source variant: `source` is evaluated for tracking, `callback(next, prev)` runs on change, and returning a cleanup function is honored.
78
+
79
+ ## Batching
80
+
81
+ ```ts
23
82
  batch(() => {
24
83
  count.set(1)
25
84
  count.set(2)
85
+ }) // subscribers notified ONCE with count=2
86
+ ```
87
+
88
+ `batch()` defers subscriber notifications until the end of the callback. `nextTick(): Promise<void>` resolves after the current flush — useful for awaiting DOM updates in tests.
89
+
90
+ ## Stores
91
+
92
+ ```ts
93
+ const store = createStore({ count: 0, todos: [{ text: 'a', done: false }] })
94
+ store.count++ // notifies
95
+ store.todos[0].done = true // deep — notifies
96
+
97
+ const shallow = shallowReactive({ user: { name: 'a' } })
98
+ shallow.user = { name: 'b' } // notifies
99
+ shallow.user.name = 'c' // does NOT notify (shallow)
100
+
101
+ const raw = markRaw(thirdPartyClassInstance) // skip proxy
102
+ ```
103
+
104
+ `createStore` returns a deeply-reactive proxy. `shallowReactive` proxies only the top level. `markRaw` opts an object out of proxying — useful for class instances, DOM nodes, third-party objects. `reconcile(target, source)` patches an existing store to match `source` without remounting.
105
+
106
+ **Caveat:** `Map`, `Set`, `WeakMap`, `WeakSet`, `Date`, `RegExp`, `Promise`, `Error` are returned RAW. Mutating them does not notify; assign a new instance to trigger updates.
107
+
108
+ ## Resources
109
+
110
+ ```ts
111
+ const user = createResource(() => userId(), async (id) => {
112
+ const r = await fetch(`/api/users/${id}`)
113
+ return r.json()
26
114
  })
27
- // logs "doubled: 4" once
115
+
116
+ user.data() // T | undefined
117
+ user.loading() // boolean
118
+ user.error() // Error | undefined
119
+ user.refetch()
120
+ ```
121
+
122
+ `createResource(source, fetcher)` re-runs the fetcher whenever `source` changes; stale responses are dropped via an internal request-id guard. Resources created **outside** an `EffectScope` must be `dispose()`-d explicitly to avoid leaks.
123
+
124
+ ## EffectScope
125
+
126
+ ```ts
127
+ const scope = effectScope()
128
+ scope.runInScope(() => {
129
+ effect(() => console.log(count()))
130
+ onScopeDispose(() => console.log('scope ended'))
131
+ })
132
+ scope.stop() // disposes every effect inside
28
133
  ```
29
134
 
30
- ## API
135
+ Groups effects for bulk disposal — used internally by `@pyreon/runtime-dom`'s mount pipeline. `getCurrentScope()` returns the active scope; `setCurrentScope(scope)` is the escape hatch for advanced cross-tree integrations.
31
136
 
32
- ### Signals
137
+ Internal arrays (`_effects`, `_updateHooks`) are lazy-allocated — scopes with no effects cost only the object itself.
33
138
 
34
- - **`signal<T>(initial: T, options?): Signal<T>`** -- Callable getter with `.set(value)` and `.update(fn)` methods. Pass `{ name }` for debug labels (auto-injected by `@pyreon/vite-plugin` in dev mode).
35
- - **`computed<T>(fn, options?): Computed<T>`** -- Derived signal that recomputes lazily when dependencies change.
36
- - **`cell<T>(initial: T): Cell<T>`** -- Lightweight reactive cell.
139
+ ## Selectors
37
140
 
38
- ### Effects
141
+ ```ts
142
+ const selected = signal<string | null>(null)
143
+ const isSelected = createSelector(() => selected())
39
144
 
40
- - **`effect(fn): Effect`** -- Runs `fn` and re-runs it whenever its tracked dependencies change.
41
- - **`onCleanup(fn)`** -- Registers a cleanup function inside an effect. Runs before re-execution and on disposal.
42
- - **`renderEffect(fn): Effect`** -- Like `effect`, but scheduled for render timing.
43
- - **`watch(source, callback, options?): WatchOptions`** -- Watches a reactive source and calls back on change.
44
- - **`setErrorHandler(handler)`** -- Sets a global error handler for effect errors.
145
+ <For each={items} by={i => i.id}>
146
+ {(item) => <li class={() => isSelected(item.id) ? 'active' : ''}>{item.name}</li>}
147
+ </For>
148
+ ```
45
149
 
46
- ### Batching
150
+ `createSelector(source)` returns a function that, when called with a key, only notifies subscribers when the key transitions in or out of the selected state. O(1) instead of N effect runs on selection change.
47
151
 
48
- - **`batch(fn)`** -- Groups multiple signal writes; subscribers notified once at the end.
49
- - **`nextTick(): Promise<void>`** -- Resolves after the current batch of updates flushes.
152
+ ## Cell minimal alternative to signal
50
153
 
51
- ### Tracking
154
+ ```ts
155
+ import { cell } from '@pyreon/reactivity'
156
+ const c = cell(0)
157
+ c.get(); c.set(1); c.subscribe(listener)
158
+ ```
52
159
 
53
- - **`runUntracked(fn)`** -- Runs `fn` without tracking any signal reads.
54
- - **`untrack(fn)`** -- Alias for `runUntracked`.
160
+ `cell()` is a class-based primitive with a single-listener fast path and one allocation per cell. It is **not** callable and **does not** participate in effect tracking use it only for cross-cutting state where the signal-tracking overhead would be wasteful.
55
161
 
56
- ### Scopes
162
+ ## Debugging
57
163
 
58
- - **`effectScope(): EffectScope`** -- Creates a scope that collects effects for bulk disposal. Internal arrays (`_effects`, `_updateHooks`) are lazy-allocated on first use -- scopes with no effects cost only the object itself.
59
- - **`getCurrentScope(): EffectScope | null`** -- Returns the active effect scope, or `null` if none.
60
- - **`onScopeDispose(fn)`** -- Register a callback to run when the current scope stops (Vue 3 parity).
61
- - **`setCurrentScope(scope)`** -- Manually sets the current effect scope.
164
+ ```ts
165
+ import { setErrorHandler, inspectSignal, onSignalUpdate, why, getReactiveTrace } from '@pyreon/reactivity'
166
+
167
+ setErrorHandler((err, source) => reportToSentry(err, { tag: source }))
62
168
 
63
- ### Selectors and Resources
169
+ const count = signal(0, { name: 'count' })
170
+ onSignalUpdate(count, (next, prev) => console.log('count', prev, '→', next))
171
+ inspectSignal(count) // { name, value, subscribers: number }
172
+ why(count) // print dependency graph for this signal
173
+ ```
64
174
 
65
- - **`createSelector(source)`** -- Creates an efficient selector for keyed comparisons.
66
- - **`createResource(fetcher): Resource<T>`** -- Wraps an async data source in a reactive resource.
175
+ `activate/deactivate/getReactiveGraph/getReactiveFires` form the **opt-in** bridge consumed by the Pyreon devtools zero cost until activated, gated by `process.env.NODE_ENV !== 'production'`, tree-shaken in production.
67
176
 
68
- ### Stores
177
+ ## Documentation
69
178
 
70
- - **`createStore(initial)`** -- Creates a deeply reactive store object.
71
- - **`isStore(value): boolean`** -- Checks whether a value is a reactive store.
72
- - **`reconcile(target, source)`** -- Efficiently patches a store to match a new value.
73
- - **`shallowReactive<T>(initial): T`** -- Creates a SHALLOWLY reactive store: top-level property writes notify, but nested object mutations don't (Vue 3 parity). Use for large object graphs where deep proxying would be wasteful.
74
- - **`markRaw<T>(value): T`** -- Mark an object as RAW so `createStore` and `shallowReactive` return it unwrapped (Vue 3 parity). Useful for class instances, third-party objects, DOM nodes, or any shape that shouldn't be deeply proxied. Marking is one-way (no `unmarkRaw`); mark BEFORE the object enters a store.
179
+ Full docs: [docs.pyreon.dev/docs/reactivity](https://docs.pyreon.dev/docs/reactivity) (or `docs/docs/reactivity.md` in this repo).
75
180
 
76
181
  ## License
77
182
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/reactivity",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Signals-based reactivity system for Pyreon",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/reactivity#readme",
6
6
  "bugs": {