@pyreon/reactivity 0.12.15 → 0.13.1
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 +4 -1
- package/src/manifest.ts +226 -0
- package/src/tests/manifest-snapshot.test.ts +78 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/reactivity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
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.1"
|
|
38
|
+
},
|
|
36
39
|
"scripts": {
|
|
37
40
|
"build": "vl_rolldown_build",
|
|
38
41
|
"dev": "vl_rolldown_build-watch",
|
package/src/manifest.ts
ADDED
|
@@ -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
|
+
})
|