@pyreon/reactivity 0.12.14 → 0.13.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/reactivity",
3
- "version": "0.12.14",
3
+ "version": "0.13.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": {
@@ -33,6 +33,9 @@
33
33
  "publishConfig": {
34
34
  "access": "public"
35
35
  },
36
+ "devDependencies": {
37
+ "@pyreon/manifest": "0.13.0"
38
+ },
36
39
  "scripts": {
37
40
  "build": "vl_rolldown_build",
38
41
  "dev": "vl_rolldown_build-watch",
@@ -0,0 +1,226 @@
1
+ import { defineManifest } from '@pyreon/manifest'
2
+
3
+ export default defineManifest({
4
+ name: '@pyreon/reactivity',
5
+ title: 'Complete API',
6
+ tagline:
7
+ 'Fine-grained reactivity: signal, computed, effect, batch, onCleanup, createStore, watch, createResource, untrack',
8
+ description:
9
+ 'Standalone reactive primitives — no DOM, no JSX, no framework dependency. Signals are callable functions (`count()` to read, `count.set(5)` to write, `count.update(n => n + 1)` to derive). Subscribers tracked via `Set<() => void>`; batch uses pointer swap for zero-allocation grouping. Every other Pyreon package builds on this foundation but `@pyreon/reactivity` can be used independently in Node, Bun, or browser scripts without any framework overhead.',
10
+ category: 'universal',
11
+ longExample: `import { signal, computed, effect, batch, onCleanup, createStore, watch, untrack } from "@pyreon/reactivity"
12
+
13
+ // signal<T>() — callable function, NOT .value getter/setter
14
+ const count = signal(0)
15
+ count() // read (subscribes)
16
+ count.set(5) // write
17
+ count.update(n => n + 1) // derive
18
+ count.peek() // read WITHOUT subscribing
19
+
20
+ // computed<T>() — auto-tracked, memoized
21
+ const doubled = computed(() => count() * 2)
22
+
23
+ // effect() — re-runs when dependencies change
24
+ const dispose = effect(() => {
25
+ console.log("Count:", count())
26
+ onCleanup(() => console.log("cleaning up"))
27
+ })
28
+
29
+ // batch() — group 3+ writes into a single notification pass
30
+ batch(() => {
31
+ count.set(10)
32
+ count.set(20) // subscribers fire once, with 20
33
+ })
34
+
35
+ // watch(source, callback) — explicit dependency tracking
36
+ watch(() => count(), (next, prev) => {
37
+ console.log(\`changed from \${prev} to \${next}\`)
38
+ })
39
+
40
+ // createStore() — deeply reactive object (proxy-based)
41
+ const store = createStore({ todos: [{ text: 'Learn Pyreon', done: false }] })
42
+ store.todos[0].done = true // fine-grained update, no immer needed
43
+
44
+ // untrack() — read signals without subscribing
45
+ effect(() => {
46
+ const current = count()
47
+ const other = untrack(() => otherSignal()) // won't re-run when otherSignal changes
48
+ })`,
49
+ features: [
50
+ 'signal<T>() — callable function with .set() and .update()',
51
+ 'computed<T>() — auto-tracked memoized derivation',
52
+ 'effect() — side-effect that re-runs on dependency change',
53
+ 'batch() — group multiple writes into a single notification pass',
54
+ 'onCleanup() — register cleanup inside effects',
55
+ 'watch(source, callback) — explicit reactive watcher',
56
+ 'createStore() — deeply reactive proxy-based object',
57
+ 'createResource() — async data with signal-based status',
58
+ 'untrack() — read without subscribing',
59
+ 'Standalone — zero DOM, zero JSX, zero framework dependency',
60
+ ],
61
+ api: [
62
+ {
63
+ name: 'signal',
64
+ kind: 'function',
65
+ signature: '<T>(initialValue: T, options?: { name?: string }) => Signal<T>',
66
+ summary:
67
+ 'Create a reactive signal. The returned value is a CALLABLE FUNCTION — `count()` reads (and subscribes), `count.set(v)` writes, `count.update(fn)` derives, `count.peek()` reads without subscribing. This is NOT a `.value` getter/setter pattern (React/Vue) — Pyreon signals are functions. Optional `{ name }` for debugging; auto-injected by `@pyreon/vite-plugin` in dev mode.',
68
+ example: `const count = signal(0)
69
+ count() // 0 (subscribes to updates)
70
+ count.set(5) // sets to 5
71
+ count.update(n => n + 1) // 6
72
+ count.peek() // 6 (does NOT subscribe)`,
73
+ mistakes: [
74
+ '`count.value` — does not exist. Use `count()` to read',
75
+ '`count = 5` — reassigning the variable replaces the signal, does not write to it. Use `count.set(5)`',
76
+ '`signal(5)` called with an argument after creation — reads and ignores the argument (dev mode warns). Use `.set(5)` to write',
77
+ '`const [val, setVal] = signal(0)` — signals are not destructurable tuples. The whole return value IS the signal',
78
+ '`{count}` in JSX — renders the signal function itself, not its value. Use `{count()}` or `{() => count()}`',
79
+ '`.peek()` inside `effect()` / `computed()` — bypasses tracking, creates stale reads. Only use `.peek()` for loop-prevention guards',
80
+ ],
81
+ seeAlso: ['computed', 'effect', 'batch'],
82
+ },
83
+ {
84
+ name: 'computed',
85
+ kind: 'function',
86
+ signature: '<T>(fn: () => T, options?: { equals?: (a: T, b: T) => boolean }) => Computed<T>',
87
+ summary:
88
+ 'Create a memoized derived value. Dependencies auto-tracked on each evaluation — no dependency array needed (unlike React `useMemo`). Only recomputes when a tracked signal actually changes. Custom `equals` function prevents downstream effects from firing on structurally-equal updates (default: `Object.is`).',
89
+ example: `const count = signal(0)
90
+ const doubled = computed(() => count() * 2)
91
+ doubled() // 0
92
+ count.set(5)
93
+ doubled() // 10`,
94
+ mistakes: [
95
+ '`computed(() => count)` — must CALL the signal: `computed(() => count())`',
96
+ 'Using `computed()` for side effects — use `effect()` instead; computed is for pure derivation',
97
+ 'Expecting `computed()` to re-run when a `.peek()`-read signal changes — `.peek()` bypasses tracking',
98
+ ],
99
+ seeAlso: ['signal', 'effect'],
100
+ },
101
+ {
102
+ name: 'effect',
103
+ kind: 'function',
104
+ signature: '(fn: () => (() => void) | void) => () => void',
105
+ summary:
106
+ 'Run a side effect that auto-tracks signal dependencies and re-runs when they change. Returns a dispose function that unsubscribes. The effect function can return a cleanup callback (equivalent to calling `onCleanup()` inside the body) — the cleanup runs before each re-execution and on final dispose. For DOM-specific effects with lighter overhead, use `renderEffect()` instead.',
107
+ example: `const count = signal(0)
108
+ const dispose = effect(() => {
109
+ console.log("Count:", count())
110
+ onCleanup(() => console.log("cleaning up"))
111
+ })
112
+ // Or return cleanup directly:
113
+ effect(() => {
114
+ const handler = () => console.log(count())
115
+ window.addEventListener("resize", handler)
116
+ return () => window.removeEventListener("resize", handler)
117
+ })`,
118
+ mistakes: [
119
+ 'Passing a dependency array — Pyreon auto-tracks; no array needed',
120
+ '`effect(() => { count })` — must call the signal: `effect(() => { count() })`',
121
+ 'Nesting `effect()` inside `effect()` — use `computed()` for derived values instead',
122
+ 'Creating signals inside an effect — they re-create on every run; create once outside',
123
+ ],
124
+ seeAlso: ['onCleanup', 'computed', 'renderEffect'],
125
+ },
126
+ {
127
+ name: 'batch',
128
+ kind: 'function',
129
+ signature: '(fn: () => void) => void',
130
+ summary:
131
+ 'Group multiple signal writes so subscribers fire only once — after the batch completes. Uses pointer swap (zero allocation). Essential when updating 3+ signals that downstream effects read together; without batch, each `.set()` triggers an independent notification pass.',
132
+ example: `const a = signal(1)
133
+ const b = signal(2)
134
+ batch(() => {
135
+ a.set(10)
136
+ b.set(20)
137
+ })
138
+ // Effects that read both a() and b() fire once, not twice`,
139
+ mistakes: [
140
+ 'Reading a signal inside `batch()` and expecting the NEW value before the batch completes — reads inside the batch see the new value (writes are synchronous), but effects fire only after the batch callback returns',
141
+ 'Forgetting `batch()` when updating 3+ related signals — causes N intermediate re-renders',
142
+ ],
143
+ seeAlso: ['signal', 'effect'],
144
+ },
145
+ {
146
+ name: 'onCleanup',
147
+ kind: 'function',
148
+ signature: '(fn: () => void) => void',
149
+ summary:
150
+ 'Register a cleanup function inside an `effect()` or `renderEffect()`. Runs before each re-execution of the effect (when dependencies change) and once on final dispose. Equivalent to returning a cleanup function from the effect body — both forms work, `onCleanup` is useful when you need to register cleanup at a different point than the end of the body.',
151
+ example: `effect(() => {
152
+ const handler = () => console.log(count())
153
+ window.addEventListener("resize", handler)
154
+ onCleanup(() => window.removeEventListener("resize", handler))
155
+ })`,
156
+ mistakes: [
157
+ 'Using `onCleanup` outside an effect — it only works inside `effect()` or `renderEffect()` body',
158
+ 'Confusing with `onUnmount` — `onCleanup` is for effects, `onUnmount` is for component lifecycle',
159
+ ],
160
+ seeAlso: ['effect'],
161
+ },
162
+ {
163
+ name: 'watch',
164
+ kind: 'function',
165
+ signature: '<T>(source: () => T, callback: (next: T, prev: T) => void, options?: WatchOptions) => () => void',
166
+ summary:
167
+ 'Explicit reactive watcher — tracks `source` and fires `callback` when it changes. Unlike `effect()`, the callback receives both `next` and `prev` values and does NOT auto-track signals read inside the callback body. `source` is evaluated at setup time to establish tracking; reading browser globals there still fires SSR lint rules. Returns a dispose function.',
168
+ example: `watch(() => count(), (next, prev) => {
169
+ console.log(\`changed from \${prev} to \${next}\`)
170
+ })`,
171
+ mistakes: [
172
+ 'Reading browser globals in the `source` function — it runs at setup time (not just in mounted context), so `no-window-in-ssr` fires on `window.X` there',
173
+ 'Expecting signals read inside the `callback` to be tracked — only the `source` function establishes tracking; the callback is untracked',
174
+ ],
175
+ seeAlso: ['effect', 'computed'],
176
+ },
177
+ {
178
+ name: 'createStore',
179
+ kind: 'function',
180
+ signature: '<T extends object>(initial: T) => T',
181
+ summary:
182
+ 'Create a deeply reactive proxy-based object. Mutations at any depth trigger fine-grained updates — `store.todos[0].done = true` only re-runs effects that read `store.todos[0].done`, not effects that read `store.todos.length` or other items. No immer, no spread-copy, no `produce()` — just mutate. Works with nested objects, arrays, Maps, and Sets.',
183
+ example: `const store = createStore({
184
+ todos: [{ text: 'Learn Pyreon', done: false }],
185
+ filter: 'all',
186
+ })
187
+ store.todos[0].done = true // fine-grained — only 'done' subscribers fire
188
+ store.todos.push({ text: 'Build app', done: false }) // array methods work`,
189
+ mistakes: [
190
+ 'Replacing the entire store object — `store = { ... }` replaces the variable, not the proxy. Mutate properties instead: `store.filter = "active"`',
191
+ 'Destructuring store properties at setup — `const { filter } = store` captures the value once, losing reactivity. Read `store.filter` inside reactive scopes',
192
+ 'Using `createStore` for simple scalar state — use `signal()` for primitives; `createStore` adds proxy overhead that only pays off for nested objects',
193
+ ],
194
+ seeAlso: ['signal'],
195
+ },
196
+ {
197
+ name: 'untrack',
198
+ kind: 'function',
199
+ signature: '(fn: () => T) => T',
200
+ summary:
201
+ 'Execute a function reading signals WITHOUT subscribing to them. Alias for `runUntracked`. Use inside effects when you need to read a signal\'s current value as a one-shot snapshot without the effect re-running when that signal changes.',
202
+ example: `effect(() => {
203
+ const current = count() // tracked — effect re-runs on count change
204
+ const other = untrack(() => otherSignal()) // NOT tracked — just reads the current value
205
+ })`,
206
+ mistakes: [
207
+ 'Using `untrack` as the default — signals should be tracked by default; `untrack` is the escape hatch for specific optimization or loop-prevention cases',
208
+ ],
209
+ seeAlso: ['signal', 'effect'],
210
+ },
211
+ ],
212
+ gotchas: [
213
+ {
214
+ label: 'Signals are callable functions',
215
+ note: 'Pyreon signals are NOT `.value` getters (Vue ref) or `[state, setState]` tuples (React useState). The signal IS the function: `count()` reads, `count.set(v)` writes, `count.update(fn)` derives. This is the #1 confusion for developers coming from other frameworks.',
216
+ },
217
+ {
218
+ label: 'No dependency arrays',
219
+ note: '`effect()` and `computed()` auto-track dependencies on each execution — no `[dep1, dep2]` array needed. Every signal read inside the body is a tracked dependency. This means conditional reads (`if (cond()) { return x() }`) only track `x` when `cond()` is true.',
220
+ },
221
+ {
222
+ label: 'Standalone',
223
+ note: '`@pyreon/reactivity` has zero dependencies. Use it in Node/Bun scripts, edge workers, or any JavaScript environment without pulling in the rest of the framework. `@pyreon/core` and `@pyreon/runtime-dom` build on it but are not required.',
224
+ },
225
+ ],
226
+ })
@@ -0,0 +1,78 @@
1
+ import {
2
+ renderApiReferenceEntries,
3
+ renderLlmsFullSection,
4
+ renderLlmsTxtLine,
5
+ } from '@pyreon/manifest'
6
+ import reactivityManifest from '../manifest'
7
+
8
+ describe('gen-docs — reactivity snapshot', () => {
9
+ it('renders @pyreon/reactivity to its expected llms.txt bullet', () => {
10
+ expect(renderLlmsTxtLine(reactivityManifest)).toMatchInlineSnapshot(`"- @pyreon/reactivity — Fine-grained reactivity: signal, computed, effect, batch, onCleanup, createStore, watch, createResource, untrack. Pyreon signals are NOT \`.value\` getters (Vue ref) or \`[state, setState]\` tuples (React useState). The signal IS the function: \`count()\` reads, \`count.set(v)\` writes, \`count.update(fn)\` derives. This is the #1 confusion for developers coming from other frameworks."`)
11
+ })
12
+
13
+ it('renders @pyreon/reactivity to its expected llms-full.txt section — full body snapshot', () => {
14
+ expect(renderLlmsFullSection(reactivityManifest)).toMatchInlineSnapshot(`
15
+ "## @pyreon/reactivity — Complete API
16
+
17
+ Standalone reactive primitives — no DOM, no JSX, no framework dependency. Signals are callable functions (\`count()\` to read, \`count.set(5)\` to write, \`count.update(n => n + 1)\` to derive). Subscribers tracked via \`Set<() => void>\`; batch uses pointer swap for zero-allocation grouping. Every other Pyreon package builds on this foundation but \`@pyreon/reactivity\` can be used independently in Node, Bun, or browser scripts without any framework overhead.
18
+
19
+ \`\`\`typescript
20
+ import { signal, computed, effect, batch, onCleanup, createStore, watch, untrack } from "@pyreon/reactivity"
21
+
22
+ // signal<T>() — callable function, NOT .value getter/setter
23
+ const count = signal(0)
24
+ count() // read (subscribes)
25
+ count.set(5) // write
26
+ count.update(n => n + 1) // derive
27
+ count.peek() // read WITHOUT subscribing
28
+
29
+ // computed<T>() — auto-tracked, memoized
30
+ const doubled = computed(() => count() * 2)
31
+
32
+ // effect() — re-runs when dependencies change
33
+ const dispose = effect(() => {
34
+ console.log("Count:", count())
35
+ onCleanup(() => console.log("cleaning up"))
36
+ })
37
+
38
+ // batch() — group 3+ writes into a single notification pass
39
+ batch(() => {
40
+ count.set(10)
41
+ count.set(20) // subscribers fire once, with 20
42
+ })
43
+
44
+ // watch(source, callback) — explicit dependency tracking
45
+ watch(() => count(), (next, prev) => {
46
+ console.log(\`changed from \${prev} to \${next}\`)
47
+ })
48
+
49
+ // createStore() — deeply reactive object (proxy-based)
50
+ const store = createStore({ todos: [{ text: 'Learn Pyreon', done: false }] })
51
+ store.todos[0].done = true // fine-grained update, no immer needed
52
+
53
+ // untrack() — read signals without subscribing
54
+ effect(() => {
55
+ const current = count()
56
+ const other = untrack(() => otherSignal()) // won't re-run when otherSignal changes
57
+ })
58
+ \`\`\`
59
+
60
+ > **Signals are callable functions**: Pyreon signals are NOT \`.value\` getters (Vue ref) or \`[state, setState]\` tuples (React useState). The signal IS the function: \`count()\` reads, \`count.set(v)\` writes, \`count.update(fn)\` derives. This is the #1 confusion for developers coming from other frameworks.
61
+ >
62
+ > **No dependency arrays**: \`effect()\` and \`computed()\` auto-track dependencies on each execution — no \`[dep1, dep2]\` array needed. Every signal read inside the body is a tracked dependency. This means conditional reads (\`if (cond()) { return x() }\`) only track \`x\` when \`cond()\` is true.
63
+ >
64
+ > **Standalone**: \`@pyreon/reactivity\` has zero dependencies. Use it in Node/Bun scripts, edge workers, or any JavaScript environment without pulling in the rest of the framework. \`@pyreon/core\` and \`@pyreon/runtime-dom\` build on it but are not required.
65
+ "
66
+ `)
67
+ })
68
+
69
+ it('renders @pyreon/reactivity to MCP api-reference entries — one per api[] item', () => {
70
+ const record = renderApiReferenceEntries(reactivityManifest)
71
+ expect(Object.keys(record).length).toBe(8)
72
+ expect(Object.keys(record)).toContain('reactivity/signal')
73
+ // Spot-check the flagship API — signal is the core primitive
74
+ const signal = record['reactivity/signal']!
75
+ expect(signal.mistakes?.split('\n').length).toBe(6)
76
+ expect(signal.notes).toContain('CALLABLE FUNCTION')
77
+ })
78
+ })