sygnal 4.6.0 → 5.0.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/README.md +260 -60
- package/dist/astro/client.cjs.js +260 -9
- package/dist/astro/client.mjs +260 -9
- package/dist/index.cjs.js +308 -17
- package/dist/index.d.ts +89 -13
- package/dist/index.esm.js +307 -18
- package/dist/jsx-dev-runtime.cjs.js +25 -8
- package/dist/jsx-dev-runtime.esm.js +25 -8
- package/dist/jsx-runtime.cjs.js +25 -8
- package/dist/jsx-runtime.esm.js +25 -8
- package/dist/jsx.cjs.js +25 -8
- package/dist/jsx.esm.js +25 -8
- package/dist/sygnal.min.js +1 -1
- package/package.json +4 -2
- package/src/component.ts +188 -47
- package/src/cycle/dom/DocumentDOMSource.ts +2 -1
- package/src/cycle/dom/MainDOMSource.ts +2 -1
- package/src/cycle/dom/enrichEventStream.ts +78 -0
- package/src/cycle/dom/index.ts +1 -0
- package/src/extra/command.ts +42 -0
- package/src/index.d.ts +89 -13
- package/src/index.ts +2 -0
- package/src/lazy.ts +2 -0
- package/src/pragma/index.ts +24 -8
- package/src/slot.ts +11 -0
package/README.md
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
# Sygnal
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A reactive component framework with pure functions, zero side effects, and automatic state management.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/sygnal)
|
|
6
|
+
[](https://www.npmjs.com/package/sygnal)
|
|
7
|
+
[](https://github.com/tpresley/sygnal/blob/main/LICENSE)
|
|
8
|
+
[](https://pkg-size.dev/sygnal)
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
- 100% pure components with absolutely no side effects
|
|
9
|
-
- No need for component state management (it's handled automatically at the application level)
|
|
10
|
-
- Small bundle sizes
|
|
11
|
-
- Fast build times
|
|
12
|
-
- Fast rendering
|
|
13
|
-
- Close to zero boiler plate code
|
|
10
|
+
---
|
|
14
11
|
|
|
15
|
-
##
|
|
12
|
+
## Why Sygnal?
|
|
16
13
|
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
14
|
+
- **Pure components** — Views are plain functions. All side effects are handled by drivers, outside your code.
|
|
15
|
+
- **Automatic state management** — Monolithic state tree with no store setup, no providers, no hooks. Trivial undo/redo and time-travel debugging.
|
|
16
|
+
- **Model-View-Intent** — Cleanly separate *what* happens (Model), *when* it happens (Intent), and *how* it looks (View).
|
|
17
|
+
- **Tiny footprint** — Three runtime dependencies: [snabbdom](https://github.com/snabbdom/snabbdom), [xstream](https://github.com/staltz/xstream), and [extend](https://github.com/nicjohnson145/extend).
|
|
20
18
|
|
|
21
19
|
## Quick Start
|
|
22
20
|
|
|
21
|
+
**Scaffold a new project:**
|
|
22
|
+
|
|
23
23
|
```bash
|
|
24
24
|
npx degit tpresley/sygnal-template my-app
|
|
25
25
|
cd my-app
|
|
@@ -27,15 +27,15 @@ npm install
|
|
|
27
27
|
npm run dev
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
Or
|
|
30
|
+
**Or add to an existing project:**
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
33
|
npm install sygnal
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
##
|
|
36
|
+
## A Sygnal Component
|
|
37
37
|
|
|
38
|
-
A
|
|
38
|
+
A component is a function (the **view**) with static properties that define **when** things happen (`.intent`) and **what** happens (`.model`):
|
|
39
39
|
|
|
40
40
|
```jsx
|
|
41
41
|
function Counter({ state }) {
|
|
@@ -50,20 +50,18 @@ function Counter({ state }) {
|
|
|
50
50
|
|
|
51
51
|
Counter.initialState = { count: 0 }
|
|
52
52
|
|
|
53
|
-
// Intent: WHEN things happen
|
|
54
53
|
Counter.intent = ({ DOM }) => ({
|
|
55
54
|
INCREMENT: DOM.select('.increment').events('click'),
|
|
56
|
-
DECREMENT: DOM.select('.decrement').events('click')
|
|
55
|
+
DECREMENT: DOM.select('.decrement').events('click'),
|
|
57
56
|
})
|
|
58
57
|
|
|
59
|
-
// Model: WHAT happens
|
|
60
58
|
Counter.model = {
|
|
61
|
-
INCREMENT: (state) => ({ count: state.count + 1 }),
|
|
62
|
-
DECREMENT: (state) => ({ count: state.count - 1 })
|
|
59
|
+
INCREMENT: (state) => ({ ...state, count: state.count + 1 }),
|
|
60
|
+
DECREMENT: (state) => ({ ...state, count: state.count - 1 }),
|
|
63
61
|
}
|
|
64
62
|
```
|
|
65
63
|
|
|
66
|
-
Start it
|
|
64
|
+
Start it:
|
|
67
65
|
|
|
68
66
|
```javascript
|
|
69
67
|
import { run } from 'sygnal'
|
|
@@ -72,54 +70,105 @@ import Counter from './Counter.jsx'
|
|
|
72
70
|
run(Counter)
|
|
73
71
|
```
|
|
74
72
|
|
|
75
|
-
|
|
73
|
+
No store setup, no providers, no hooks — just a function and some properties.
|
|
76
74
|
|
|
77
|
-
##
|
|
75
|
+
## Features
|
|
78
76
|
|
|
79
|
-
|
|
77
|
+
### Collections
|
|
80
78
|
|
|
81
|
-
|
|
79
|
+
Render dynamic lists with built-in filtering and sorting:
|
|
80
|
+
|
|
81
|
+
```jsx
|
|
82
|
+
<Collection of={TodoItem} from="items" filter={item => !item.done} sort="name" />
|
|
83
|
+
```
|
|
82
84
|
|
|
83
|
-
|
|
85
|
+
### Switchable
|
|
84
86
|
|
|
85
|
-
|
|
87
|
+
Swap between components based on state:
|
|
86
88
|
|
|
87
|
-
|
|
89
|
+
```jsx
|
|
90
|
+
<Switchable of={{ home: HomePage, settings: SettingsPage }} current={state.activeTab} />
|
|
91
|
+
```
|
|
88
92
|
|
|
89
|
-
|
|
93
|
+
### Context
|
|
90
94
|
|
|
91
|
-
|
|
95
|
+
Top-down data propagation without prop drilling:
|
|
92
96
|
|
|
93
|
-
|
|
97
|
+
```jsx
|
|
98
|
+
App.context = {
|
|
99
|
+
theme: (state) => state.settings.theme,
|
|
100
|
+
currentUser: (state) => state.auth.user,
|
|
101
|
+
}
|
|
94
102
|
|
|
95
|
-
|
|
103
|
+
function Child({ state, context }) {
|
|
104
|
+
return <div className={context.theme}>{context.currentUser.name}</div>
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Parent-Child Communication
|
|
96
109
|
|
|
97
|
-
|
|
110
|
+
Structured message passing between components:
|
|
98
111
|
|
|
99
112
|
```jsx
|
|
100
|
-
|
|
113
|
+
// Child emits
|
|
114
|
+
TaskCard.model = {
|
|
115
|
+
DELETE: { PARENT: (state) => ({ type: 'DELETE', taskId: state.id }) }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Parent receives (use component reference — minification-safe)
|
|
119
|
+
Lane.intent = ({ CHILD }) => ({
|
|
120
|
+
TASK_DELETED: CHILD.select(TaskCard).filter(e => e.type === 'DELETE'),
|
|
121
|
+
})
|
|
101
122
|
```
|
|
102
123
|
|
|
103
|
-
###
|
|
124
|
+
### Event Bus
|
|
104
125
|
|
|
105
|
-
|
|
126
|
+
Global broadcast for cross-component communication:
|
|
106
127
|
|
|
107
128
|
```jsx
|
|
108
|
-
|
|
129
|
+
// Any component can emit
|
|
130
|
+
Publisher.model = {
|
|
131
|
+
NOTIFY: { EVENTS: (state) => ({ type: 'notification', data: state.message }) }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Any component can subscribe
|
|
135
|
+
Subscriber.intent = ({ EVENTS }) => ({
|
|
136
|
+
HANDLE: EVENTS.select('notification'),
|
|
137
|
+
})
|
|
109
138
|
```
|
|
110
139
|
|
|
111
|
-
###
|
|
140
|
+
### Calculated Fields
|
|
112
141
|
|
|
113
|
-
|
|
142
|
+
Derived state with optional dependency tracking:
|
|
114
143
|
|
|
115
144
|
```jsx
|
|
116
|
-
|
|
145
|
+
Invoice.calculated = {
|
|
146
|
+
subtotal: [['items'], (state) => sum(state.items.map(i => i.price))],
|
|
147
|
+
tax: [['subtotal'], (state) => state.subtotal * 0.08],
|
|
148
|
+
total: [['subtotal', 'tax'], (state) => state.subtotal + state.tax],
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Form Handling
|
|
117
153
|
|
|
154
|
+
Extract form values without the boilerplate:
|
|
155
|
+
|
|
156
|
+
```jsx
|
|
118
157
|
MyForm.intent = ({ DOM }) => ({
|
|
119
|
-
SUBMITTED: processForm(DOM.select('.my-form'), { events: 'submit' })
|
|
158
|
+
SUBMITTED: processForm(DOM.select('.my-form'), { events: 'submit' }),
|
|
120
159
|
})
|
|
121
160
|
```
|
|
122
161
|
|
|
162
|
+
### Drag and Drop
|
|
163
|
+
|
|
164
|
+
HTML5 drag-and-drop with a dedicated driver:
|
|
165
|
+
|
|
166
|
+
```javascript
|
|
167
|
+
import { makeDragDriver } from 'sygnal'
|
|
168
|
+
|
|
169
|
+
run(RootComponent, { DND: makeDragDriver() })
|
|
170
|
+
```
|
|
171
|
+
|
|
123
172
|
### Custom Drivers
|
|
124
173
|
|
|
125
174
|
Wrap any async operation as a driver:
|
|
@@ -135,6 +184,146 @@ const apiDriver = driverFromAsync(async (url) => {
|
|
|
135
184
|
run(RootComponent, { API: apiDriver })
|
|
136
185
|
```
|
|
137
186
|
|
|
187
|
+
### Error Boundaries
|
|
188
|
+
|
|
189
|
+
Catch and recover from rendering errors:
|
|
190
|
+
|
|
191
|
+
```jsx
|
|
192
|
+
BrokenComponent.onError = (error, { componentName }) => (
|
|
193
|
+
<div>Something went wrong in {componentName}</div>
|
|
194
|
+
)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Portals
|
|
198
|
+
|
|
199
|
+
Render children into a different DOM container:
|
|
200
|
+
|
|
201
|
+
```jsx
|
|
202
|
+
<Portal target="#modal-root">
|
|
203
|
+
<div className="modal">Modal content</div>
|
|
204
|
+
</Portal>
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Slots
|
|
208
|
+
|
|
209
|
+
Pass named content regions to child components:
|
|
210
|
+
|
|
211
|
+
```jsx
|
|
212
|
+
import { Slot } from 'sygnal'
|
|
213
|
+
|
|
214
|
+
<Card state="card">
|
|
215
|
+
<Slot name="header"><h2>Title</h2></Slot>
|
|
216
|
+
<Slot name="actions"><button>Save</button></Slot>
|
|
217
|
+
<p>Default content</p>
|
|
218
|
+
</Card>
|
|
219
|
+
|
|
220
|
+
// In Card's view:
|
|
221
|
+
function Card({ state, slots }) {
|
|
222
|
+
return (
|
|
223
|
+
<div>
|
|
224
|
+
<header>{...(slots.header || [])}</header>
|
|
225
|
+
<main>{...(slots.default || [])}</main>
|
|
226
|
+
<footer>{...(slots.actions || [])}</footer>
|
|
227
|
+
</div>
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Transitions
|
|
233
|
+
|
|
234
|
+
CSS-based enter/leave animations:
|
|
235
|
+
|
|
236
|
+
```jsx
|
|
237
|
+
<Transition name="fade" duration={300}>
|
|
238
|
+
{state.visible && <div>Animated content</div>}
|
|
239
|
+
</Transition>
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Lazy Loading & Suspense
|
|
243
|
+
|
|
244
|
+
Code-split components with loading boundaries:
|
|
245
|
+
|
|
246
|
+
```jsx
|
|
247
|
+
const HeavyChart = lazy(() => import('./HeavyChart.jsx'))
|
|
248
|
+
|
|
249
|
+
<Suspense fallback={<div>Loading...</div>}>
|
|
250
|
+
<HeavyChart />
|
|
251
|
+
</Suspense>
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Refs
|
|
255
|
+
|
|
256
|
+
Access DOM elements declaratively:
|
|
257
|
+
|
|
258
|
+
```jsx
|
|
259
|
+
const inputRef = createRef()
|
|
260
|
+
<input ref={inputRef} />
|
|
261
|
+
// inputRef.current.focus()
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Commands
|
|
265
|
+
|
|
266
|
+
Send imperative commands from parent to child:
|
|
267
|
+
|
|
268
|
+
```jsx
|
|
269
|
+
import { createCommand } from 'sygnal'
|
|
270
|
+
|
|
271
|
+
const playerCmd = createCommand()
|
|
272
|
+
<VideoPlayer commands={playerCmd} />
|
|
273
|
+
|
|
274
|
+
// Parent sends commands with optional data
|
|
275
|
+
playerCmd.send('seek', { time: 30 })
|
|
276
|
+
|
|
277
|
+
// Child receives via commands$ source
|
|
278
|
+
VideoPlayer.intent = ({ commands$ }) => ({
|
|
279
|
+
SEEK: commands$.select('seek'), // emits { time: 30 }
|
|
280
|
+
})
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Effect Handlers
|
|
284
|
+
|
|
285
|
+
Run side effects without state changes — no more `ABORT` workarounds:
|
|
286
|
+
|
|
287
|
+
```jsx
|
|
288
|
+
App.model = {
|
|
289
|
+
SEND_COMMAND: {
|
|
290
|
+
EFFECT: () => playerCmd.send('play'),
|
|
291
|
+
},
|
|
292
|
+
ROUTE: {
|
|
293
|
+
EFFECT: (state, data, next) => {
|
|
294
|
+
if (state.mode === 'a') next('DO_A', data)
|
|
295
|
+
else next('DO_B', data)
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### Model Shorthand
|
|
302
|
+
|
|
303
|
+
Compact syntax for single-driver model entries:
|
|
304
|
+
|
|
305
|
+
```jsx
|
|
306
|
+
App.model = {
|
|
307
|
+
'SEND_CMD | EFFECT': () => playerCmd.send('play'),
|
|
308
|
+
'NOTIFY | EVENTS': (state) => ({ type: 'alert', data: state.message }),
|
|
309
|
+
'DELETE | PARENT': (state) => ({ type: 'DELETE', id: state.id }),
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### Disposal Hooks
|
|
314
|
+
|
|
315
|
+
Cleanup on unmount:
|
|
316
|
+
|
|
317
|
+
```jsx
|
|
318
|
+
MyComponent.intent = ({ dispose$ }) => ({
|
|
319
|
+
CLEANUP: dispose$,
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
MyComponent.model = {
|
|
323
|
+
CLEANUP: { WEBSOCKET: () => ({ type: 'close' }) },
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
138
327
|
### Hot Module Replacement
|
|
139
328
|
|
|
140
329
|
State-preserving HMR out of the box:
|
|
@@ -150,6 +339,8 @@ if (import.meta.hot) {
|
|
|
150
339
|
|
|
151
340
|
### Astro Integration
|
|
152
341
|
|
|
342
|
+
First-class Astro support with server rendering and client hydration:
|
|
343
|
+
|
|
153
344
|
```javascript
|
|
154
345
|
// astro.config.mjs
|
|
155
346
|
import sygnal from 'sygnal/astro'
|
|
@@ -163,24 +354,24 @@ import Counter from '../components/Counter.jsx'
|
|
|
163
354
|
<Counter client:load />
|
|
164
355
|
```
|
|
165
356
|
|
|
166
|
-
### TypeScript
|
|
357
|
+
### TypeScript
|
|
167
358
|
|
|
168
359
|
Full type definitions included:
|
|
169
360
|
|
|
170
361
|
```tsx
|
|
171
362
|
import type { RootComponent } from 'sygnal'
|
|
172
363
|
|
|
173
|
-
type
|
|
174
|
-
type
|
|
364
|
+
type State = { count: number }
|
|
365
|
+
type Actions = { INCREMENT: null }
|
|
175
366
|
|
|
176
|
-
const App: RootComponent<
|
|
367
|
+
const App: RootComponent<State, {}, Actions> = ({ state }) => (
|
|
177
368
|
<div>{state.count}</div>
|
|
178
369
|
)
|
|
179
370
|
```
|
|
180
371
|
|
|
181
|
-
## Bundler Setup
|
|
372
|
+
## Bundler Setup
|
|
182
373
|
|
|
183
|
-
|
|
374
|
+
**Vite** (recommended):
|
|
184
375
|
|
|
185
376
|
```javascript
|
|
186
377
|
// vite.config.js
|
|
@@ -188,11 +379,11 @@ export default defineConfig({
|
|
|
188
379
|
esbuild: {
|
|
189
380
|
jsx: 'automatic',
|
|
190
381
|
jsxImportSource: 'sygnal',
|
|
191
|
-
}
|
|
382
|
+
},
|
|
192
383
|
})
|
|
193
384
|
```
|
|
194
385
|
|
|
195
|
-
For TypeScript projects,
|
|
386
|
+
For TypeScript projects, add to `tsconfig.json`:
|
|
196
387
|
|
|
197
388
|
```json
|
|
198
389
|
{
|
|
@@ -203,25 +394,34 @@ For TypeScript projects, also add to `tsconfig.json`:
|
|
|
203
394
|
}
|
|
204
395
|
```
|
|
205
396
|
|
|
206
|
-
Without JSX, use `h()
|
|
397
|
+
Without JSX, use `h()` directly:
|
|
207
398
|
|
|
208
399
|
```javascript
|
|
209
400
|
import { h } from 'sygnal'
|
|
210
401
|
h('div', [h('h1', 'Hello'), h('button.btn', 'Click')])
|
|
211
402
|
```
|
|
212
403
|
|
|
213
|
-
|
|
404
|
+
## Documentation
|
|
405
|
+
|
|
406
|
+
📖 **[sygnal.js.org](https://sygnal.js.org)** — Full guide, API reference, and examples.
|
|
214
407
|
|
|
215
408
|
## Examples
|
|
216
409
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
410
|
+
| Example | Description |
|
|
411
|
+
|---------|-------------|
|
|
412
|
+
| [Getting Started](./examples/getting-started) | Interactive guide with live demos (Astro) |
|
|
413
|
+
| [Kanban Board](./examples/kanban) | Drag-and-drop with Collections and cross-component communication |
|
|
414
|
+
| [Advanced Features](./examples/advanced-feature-tests) | Portals, slots, disposal, suspense, lazy loading |
|
|
415
|
+
| [TypeScript 2048](./examples/ts-example-2048) | Full game in TypeScript |
|
|
416
|
+
| [AI Discussion Panel](./examples/ai-panel-spa) | Complex SPA with custom drivers |
|
|
417
|
+
| [Sygnal ToDoMVC](https://github.com/tpresley/sygnal-todomvc) | [Live Demo](https://tpresley.github.io/sygnal-todomvc/) |
|
|
418
|
+
| [Sygnal 2048](https://github.com/tpresley/sygnal-2048) | [Live Demo](https://tpresley.github.io/sygnal-2048/) |
|
|
419
|
+
| [Sygnal Calculator](https://github.com/tpresley/sygnal-calculator) | [Live Demo](https://tpresley.github.io/sygnal-calculator/) |
|
|
420
|
+
|
|
421
|
+
## Acknowledgments
|
|
422
|
+
|
|
423
|
+
Sygnal's reactive architecture is built on patterns from [Cycle.js](https://cycle.js.org/) by [André Staltz](https://github.com/staltz). The Cycle.js runtime, DOM driver, state management, and isolation modules have been absorbed into the library — snabbdom, xstream, and extend are the only external dependencies.
|
|
224
424
|
|
|
225
425
|
## License
|
|
226
426
|
|
|
227
|
-
MIT
|
|
427
|
+
[MIT](./LICENSE)
|