@igojs/signal 6.0.0-beta.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/README.md ADDED
@@ -0,0 +1,203 @@
1
+ # @igojs/signal
2
+
3
+ Reactive frontend/SSR framework for Igo.js with automatic dependency tracking.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ npm install @igojs/signal
9
+ ```
10
+
11
+ ## Features
12
+
13
+ - **Reactive State** - Deep reactivity via Proxy (like Vue 3)
14
+ - **Auto-tracking** - Automatic dependency detection for computed values
15
+ - **SSR Support** - Server-side rendering with hydration
16
+ - **DiffDOM** - Efficient DOM reconciliation
17
+ - **Form Handling** - Two-way binding for forms
18
+ - **Dust Templates** - Integration with @igojs/dust
19
+
20
+ ## Architecture
21
+
22
+ ```
23
+ Props (immutable) → State (reactive) → Derived (computed) → Template → DOM
24
+ ↓ ↓
25
+ Proxy tracking DiffDOM reconciliation
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ ### Server Setup
31
+
32
+ ```javascript
33
+ const express = require('express');
34
+ const signal = require('@igojs/signal');
35
+
36
+ const app = express();
37
+
38
+ // Configure translations (optional)
39
+ signal.configure({
40
+ translations: require('./locales/fr/translation.json')
41
+ });
42
+
43
+ // Add signal middleware
44
+ app.use(signal.middleware);
45
+
46
+ // Template endpoint for client-side loading
47
+ app.get('/__signal/templates', signal.templates);
48
+
49
+ // Controller
50
+ app.get('/products', (req, res) => {
51
+ res.locals.signal_props = {
52
+ products: await Product.list(),
53
+ form: { search: req.query.search || '' }
54
+ };
55
+ res.render('products/index');
56
+ });
57
+ ```
58
+
59
+ ### Component Definition
60
+
61
+ ```javascript
62
+ // components/ProductList.js
63
+ const { SignalComponent } = require('@igojs/signal');
64
+
65
+ class ProductList extends SignalComponent {
66
+ constructor(element) {
67
+ super(element, 'components/ProductList');
68
+ }
69
+
70
+ // Computed value with auto-tracking
71
+ get filteredProducts() {
72
+ const search = this.state.form?.search?.toLowerCase() || '';
73
+ return this.props.products.filter(p =>
74
+ p.name.toLowerCase().includes(search)
75
+ );
76
+ }
77
+
78
+ // Event handlers
79
+ get events() {
80
+ return [
81
+ { selector: '.delete-btn', eventType: 'click', handler: this.onDelete }
82
+ ];
83
+ }
84
+
85
+ async onDelete(e) {
86
+ const id = e.target.dataset.id;
87
+ await fetch(`/api/products/${id}`, { method: 'DELETE' });
88
+ this.props.products = this.props.products.filter(p => p.id !== id);
89
+ }
90
+ }
91
+
92
+ module.exports = ProductList;
93
+ ```
94
+
95
+ ### Template (Dust)
96
+
97
+ ```dust
98
+ {! views/components/ProductList.dust !}
99
+ <div data-component="components/ProductList" data-props="{@serialize props="products,form" /}">
100
+ <input type="text" name="search" value="{form.search}" placeholder="Search...">
101
+
102
+ <ul>
103
+ {#filteredProducts}
104
+ <li>
105
+ {name} - ${price}
106
+ <button class="delete-btn" data-id="{id}">Delete</button>
107
+ </li>
108
+ {/filteredProducts}
109
+ </ul>
110
+ </div>
111
+ ```
112
+
113
+ ### Client Entry Point
114
+
115
+ ```javascript
116
+ // assets/js/app.js
117
+ const signal = require('@igojs/signal/src/client');
118
+
119
+ signal.start({
120
+ components: require.context('./components', true, /\.js$/),
121
+ helpers: require('./helpers')
122
+ });
123
+ ```
124
+
125
+ ## API
126
+
127
+ ### Server
128
+
129
+ | Export | Description |
130
+ |--------|-------------|
131
+ | `middleware` | Express middleware for SSR |
132
+ | `templates` | Endpoint for client template loading |
133
+ | `configure({ translations })` | Configure i18n translations |
134
+ | `serialize(data)` | Serialize data for hydration |
135
+ | `SignalComponent` | Base component class |
136
+
137
+ ### SignalComponent
138
+
139
+ | Property/Method | Description |
140
+ |-----------------|-------------|
141
+ | `this.props` | Immutable props from server |
142
+ | `this.state` | Reactive state (triggers re-render on change) |
143
+ | `this.rawState` | Direct state access (no reactivity) |
144
+ | `get xyz()` | Computed values with auto-tracking |
145
+ | `get events()` | Event bindings `[{selector, eventType, handler}]` |
146
+ | `beforeRender()` | Lifecycle hook before render |
147
+ | `afterRender()` | Lifecycle hook after render |
148
+ | `destroy()` | Cleanup component |
149
+
150
+ ### Reactivity
151
+
152
+ ```javascript
153
+ // Deep reactivity
154
+ this.state.user = { name: 'John' };
155
+ this.state.user.name = 'Jane'; // Triggers render
156
+ this.state.items.push({ id: 1 }); // Triggers render
157
+ this.state.items[0].active = true; // Triggers render
158
+ ```
159
+
160
+ ### Form Handling
161
+
162
+ Forms are automatically bound when `props.form` exists:
163
+
164
+ ```javascript
165
+ // In your component
166
+ get selectedProduct() {
167
+ const id = Number(this.state.form?.product_id);
168
+ return this.props.products.find(p => p.id === id);
169
+ }
170
+ ```
171
+
172
+ ```dust
173
+ <select name="product_id">
174
+ {#products}
175
+ <option value="{id}" {@selected key=id value=form.product_id /}>{name}</option>
176
+ {/products}
177
+ </select>
178
+ ```
179
+
180
+ ## Module Structure
181
+
182
+ ```
183
+ src/
184
+ ├── client/ # Browser code
185
+ │ ├── index.js # Entry point, start()
186
+ │ ├── SignalComponent.js # Base component class
187
+ │ ├── StateProxy.js # Deep reactivity
188
+ │ ├── DerivedCache.js # Computed value memoization
189
+ │ ├── EventBinder.js # Event listener management
190
+ │ ├── FormHandler.js # Two-way form binding
191
+ │ └── dust/
192
+ │ ├── Templates.js # Template loading
193
+ │ ├── Utils.js # Dust helpers/filters
194
+ │ └── i18n.js # i18next setup
195
+ └── server/ # Node.js code
196
+ ├── index.js # Server exports
197
+ ├── SignalController.js # SSR middleware
198
+ └── SerializeUtils.js # Data serialization
199
+ ```
200
+
201
+ ## Documentation
202
+
203
+ See the [full documentation](https://igocreate.github.io/igo/#/signal/components).
package/index.js ADDED
@@ -0,0 +1,24 @@
1
+ // @igojs/signal - Reactive frontend/SSR framework for Igo.js
2
+
3
+ // Server-side exports
4
+ const server = require('./src/server');
5
+
6
+ // Re-export server utilities
7
+ module.exports = {
8
+ // Server middleware for SSR
9
+ middleware: server.middleware,
10
+
11
+ // Template serving endpoint
12
+ templates: server.templates,
13
+
14
+ // Configure signal (translations, etc.)
15
+ configure: server.configure,
16
+
17
+ // Serialization for client hydration
18
+ serialize: server.serialize,
19
+
20
+ // SignalComponent for SSR rendering
21
+ get SignalComponent() {
22
+ return require('./src/client/SignalComponent');
23
+ },
24
+ };
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@igojs/signal",
3
+ "version": "6.0.0-beta.1",
4
+ "description": "Signal - Reactive frontend/SSR framework for Igo.js",
5
+ "main": "index.js",
6
+ "browser": "src/client/index.js",
7
+ "scripts": {
8
+ "test": "mocha --exit 'test/**/*.js'"
9
+ },
10
+ "keywords": [
11
+ "frontend",
12
+ "ssr",
13
+ "reactive",
14
+ "signals",
15
+ "components"
16
+ ],
17
+ "author": "@igocreate",
18
+ "license": "ISC",
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "dependencies": {
23
+ "@igojs/server": "*",
24
+ "@igojs/dust": "*",
25
+ "devalue": "^5.6.1",
26
+ "diff-dom": "^5.2.1",
27
+ "i18next": "^25.7.3",
28
+ "lodash": "^4.17.21"
29
+ }
30
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * DerivedCache - Memoization with auto-tracked dependencies (like Vue 3 computed)
3
+ *
4
+ * Caches expensive calculations and recalculates only when dependencies change.
5
+ * Uses Proxy-based dependency tracking to automatically detect what values are accessed.
6
+ */
7
+ class DerivedCache {
8
+ constructor() {
9
+ this._cache = new Map(); // Map<key, { value, deps }>
10
+ }
11
+
12
+ // Memoize a derived value with optional precomputed value
13
+ memoize(key, fn, deps, context, precomputedValue) {
14
+ const resolvedDeps = context ? this._resolveDeps(deps, context) : deps;
15
+
16
+ // If precomputed value provided, use it directly (first render optimization)
17
+ if (precomputedValue !== undefined) {
18
+ this._cache.set(key, { value: precomputedValue, deps: [...resolvedDeps], fn });
19
+ return precomputedValue;
20
+ }
21
+
22
+ // Check cache for existing value
23
+ const cached = this._cache.get(key);
24
+ if (cached && this._areDepsEqual(cached.deps, resolvedDeps)) {
25
+ return cached.value;
26
+ }
27
+
28
+ // Recompute
29
+ const value = fn();
30
+ this._cache.set(key, { value, deps: [...resolvedDeps] });
31
+ return value;
32
+ }
33
+
34
+ // Resolve dependency paths like ['props', 'products'] to actual values
35
+ _resolveDeps(deps, context) {
36
+ return deps.map(dep => {
37
+ if (!Array.isArray(dep)) return dep;
38
+
39
+ let value = context;
40
+ for (const key of dep) value = value?.[key];
41
+ return value;
42
+ });
43
+ }
44
+
45
+ // Shallow comparison (Object.is like React)
46
+ _areDepsEqual(prevDeps, nextDeps) {
47
+ if (prevDeps.length !== nextDeps.length) return false;
48
+ for (let i = 0; i < prevDeps.length; i++) {
49
+ if (!Object.is(prevDeps[i], nextDeps[i])) return false;
50
+ }
51
+ return true;
52
+ }
53
+
54
+ clear() {
55
+ this._cache.clear();
56
+ }
57
+
58
+ invalidate(key) {
59
+ this._cache.delete(key);
60
+ }
61
+ }
62
+
63
+ module.exports = DerivedCache;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * EventBinder - Optimized event listener management with WeakMap caching
3
+ *
4
+ * Similar to how modern frameworks (Vue, Svelte, Solid) optimize event binding,
5
+ * this class avoids unnecessary rebinding by caching listeners and reusing them
6
+ * when DOM elements are preserved by DiffDOM.
7
+ *
8
+ * Strategy: WeakMap<Element, Map<eventType, handler>>
9
+ * - Outer WeakMap: Element → Map of events (automatic garbage collection)
10
+ * - Inner Map: eventType → handler (supports multiple events per element)
11
+ *
12
+ * Performance:
13
+ * - O(1) lookup to check if listener exists
14
+ * - No rebinding if element is preserved by DiffDOM
15
+ * - Automatic cleanup when elements are removed (WeakMap GC)
16
+ */
17
+ class EventBinder {
18
+ constructor() {
19
+ // WeakMap<Element, Map<eventType, handler>>
20
+ this._elementListeners = new WeakMap();
21
+ this._boundListeners = [];
22
+ }
23
+
24
+ /**
25
+ * Bind events to elements with optimization
26
+ *
27
+ * @param {HTMLElement} rootElement - Root element to search within
28
+ * @param {Array} events - Array of {selector, eventType, handler}
29
+ * @param {Object} context - Context to bind handlers to (usually the component)
30
+ */
31
+ bind(rootElement, events, context) {
32
+ if (!events || !Array.isArray(events)) return;
33
+
34
+ const newListeners = [];
35
+ const processedElements = new Map(); // Map<Element, Set<eventType>>
36
+
37
+ events.forEach(({ selector, eventType, handler }) => {
38
+ // Support 'document'/'window' strings as selector for global events
39
+ let elements;
40
+ if (selector === 'document') {
41
+ elements = [document];
42
+ } else if (selector === 'window') {
43
+ elements = [window];
44
+ } else {
45
+ elements = rootElement.querySelectorAll(selector);
46
+ }
47
+
48
+ elements.forEach(targetElement => {
49
+ // Skip warning for global selectors (document/window)
50
+ const isGlobalSelector = targetElement === document || targetElement === window;
51
+
52
+ // Warn if trying to bind to an element inside a child component
53
+ // Only warn if targeting elements INSIDE a child component, not the component's root element itself
54
+ if (!isGlobalSelector) {
55
+ const closestComponent = targetElement.closest('[data-component]');
56
+ const isTargetingChildComponentRoot = closestComponent === targetElement;
57
+ if (closestComponent && closestComponent !== rootElement && !isTargetingChildComponentRoot) {
58
+ console.warn(
59
+ `[EventBinder] Warning: Attempting to bind ${eventType} event to selector "${selector}" ` +
60
+ `which is inside a child component (${closestComponent.dataset.component}). ` +
61
+ `Consider listening to a custom event from the child component instead.`,
62
+ { parentComponent: context.constructor?.name, childComponent: closestComponent.dataset.component, targetElement }
63
+ );
64
+ }
65
+ }
66
+
67
+ // Get or create event map for this element
68
+ let eventMap = this._elementListeners.get(targetElement);
69
+ if (!eventMap) {
70
+ eventMap = new Map();
71
+ this._elementListeners.set(targetElement, eventMap);
72
+ }
73
+
74
+ // Check if this (element, eventType) already has a listener
75
+ const existingHandler = eventMap.get(eventType);
76
+
77
+ if (existingHandler) {
78
+ // ✅ Element preserved by DiffDOM → reuse existing listener
79
+ newListeners.push({
80
+ element: targetElement,
81
+ eventType,
82
+ handler: existingHandler
83
+ });
84
+ } else {
85
+ // ✅ New element or new eventType → create new listener
86
+ const boundHandler = handler.bind(context);
87
+ targetElement.addEventListener(eventType, boundHandler);
88
+ eventMap.set(eventType, boundHandler);
89
+ newListeners.push({
90
+ element: targetElement,
91
+ eventType,
92
+ handler: boundHandler
93
+ });
94
+ }
95
+
96
+ // Track processed elements for cleanup
97
+ if (!processedElements.has(targetElement)) {
98
+ processedElements.set(targetElement, new Set());
99
+ }
100
+ processedElements.get(targetElement).add(eventType);
101
+ });
102
+ });
103
+
104
+ // Cleanup: Remove listeners for elements that were removed or changed
105
+ this._boundListeners.forEach(({ element, eventType, handler }) => {
106
+ const processedEvents = processedElements.get(element);
107
+ if (!processedEvents || !processedEvents.has(eventType)) {
108
+ // Element was removed or event type changed → cleanup
109
+ element?.removeEventListener(eventType, handler);
110
+ const eventMap = this._elementListeners.get(element);
111
+ if (eventMap) {
112
+ eventMap.delete(eventType);
113
+ if (eventMap.size === 0) {
114
+ this._elementListeners.delete(element);
115
+ }
116
+ }
117
+ }
118
+ });
119
+
120
+ this._boundListeners = newListeners;
121
+ }
122
+
123
+ /**
124
+ * Unbind all listeners and clear cache
125
+ */
126
+ unbind() {
127
+ this._boundListeners.forEach(({ element, eventType, handler }) => {
128
+ element?.removeEventListener(eventType, handler);
129
+ });
130
+ this._boundListeners = [];
131
+ // Note: WeakMap clears itself automatically when elements are garbage collected
132
+ }
133
+ }
134
+
135
+ module.exports = EventBinder;
@@ -0,0 +1,94 @@
1
+ class FormHandler {
2
+ constructor(component, formData) {
3
+ this.component = component;
4
+ this.element = component.element;
5
+ this._state = component.rawState;
6
+ this._boundListeners = [];
7
+
8
+ // Initialize form state
9
+ this._state.form = this.initForm(formData);
10
+ }
11
+
12
+ // Initialize form from props.form
13
+ // Uses a shared form object (window.__signal_form) so all components share the same form state
14
+ initForm(formData) {
15
+ if (!window.__signal_form) {
16
+ window.__signal_form = { ...formData };
17
+ }
18
+ return window.__signal_form;
19
+ }
20
+
21
+ bind() {
22
+ this.element.querySelectorAll('input, select, textarea').forEach(element => {
23
+ if (!element.name) {
24
+ return;
25
+ }
26
+
27
+ // Skip inputs that are inside child components
28
+ const parentComponent = element.closest('[data-component]');
29
+ if (parentComponent && parentComponent !== this.element) {
30
+ return; // Skip this input, it belongs to a child component
31
+ }
32
+
33
+ const eventType = ['checkbox', 'radio'].includes(element.type) || element.tagName === 'SELECT' ? 'change' : 'input';
34
+ const handler = (e) => this.handleInput(e.target);
35
+
36
+ element.addEventListener(eventType, handler);
37
+ this._boundListeners.push({ element, eventType, handler });
38
+ });
39
+ }
40
+
41
+ unbind() {
42
+ this._boundListeners.forEach(({ element, eventType, handler }) => {
43
+ element?.removeEventListener(eventType, handler);
44
+ });
45
+ this._boundListeners = [];
46
+ }
47
+
48
+ // Handle form input change
49
+ handleInput(element) {
50
+ const { name, type, value } = element;
51
+
52
+ // name[index][] format
53
+ const arrayWithIndexMatch = name.match(/^(.+)\[(\d+)\]\[\]$/);
54
+ if (arrayWithIndexMatch) {
55
+ const [, fieldName, indexStr] = arrayWithIndexMatch;
56
+ const index = parseInt(indexStr, 10);
57
+
58
+ this._state.form[fieldName] = this._state.form[fieldName] || [];
59
+
60
+ if (type === 'select-multiple') {
61
+ this._state.form[fieldName][index] = [...element.options].filter(o => o.selected).map(o => o.value);
62
+ } else if (type === 'checkbox') {
63
+ this._state.form[fieldName][index] = Array.from(this.element.querySelectorAll(`[name="${name}"]:checked`)).map(el => el.value);
64
+ } else {
65
+ this._state.form[fieldName][index] = value;
66
+ }
67
+ return this.component._triggerRender();
68
+ }
69
+
70
+ const isArray = name.endsWith('[]');
71
+ const fieldName = isArray ? name.slice(0, -2) : name;
72
+
73
+ if (type === 'checkbox') {
74
+ this._state.form[fieldName] = isArray
75
+ ? Array.from(this.element.querySelectorAll(`[name="${name}"]:checked`)).map(el => el.value)
76
+ : element.checked;
77
+ } else if (type === 'select-multiple') {
78
+ this._state.form[fieldName] = [...element.options].filter(o => o.selected).map(o => o.value);
79
+ } else if (isArray) {
80
+ this._state.form[fieldName] = this._state.form[fieldName] || [];
81
+ const elements = Array.from(this.element.querySelectorAll(`[name="${name}"]`));
82
+ const elementIndex = elements.indexOf(element);
83
+ if (elementIndex !== -1) {
84
+ this._state.form[fieldName][elementIndex] = value;
85
+ }
86
+ } else {
87
+ this._state.form[fieldName] = value;
88
+ }
89
+
90
+ this.component._triggerRender();
91
+ }
92
+ }
93
+
94
+ module.exports = FormHandler;