@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.
@@ -0,0 +1,273 @@
1
+ # Igo2Component - Architecture technique
2
+
3
+ Framework réactif minimaliste basé sur Igo-Dust, avec deep réactivité et auto-tracking des dépendances.
4
+
5
+ ## Vue d'ensemble
6
+
7
+ ```
8
+ Props (immutable) → State (reactive) → Derived (computed) → Template → DOM
9
+ ↓ ↓
10
+ Proxy tracking DiffDOM reconciliation
11
+ ```
12
+
13
+ ## Fichiers
14
+
15
+ | Fichier | LOC | Responsabilité |
16
+ |---------|-----|----------------|
17
+ | `Igo2Component.js` | ~320 | Classe de base, lifecycle, render |
18
+ | `StateProxy.js` | ~80 | Deep reactivity via Proxy |
19
+ | `EventBinder.js` | ~110 | Gestion optimisée des événements |
20
+ | `DerivedCache.js` | ~65 | Memoization des getters |
21
+ | `FormHandler.js` | ~125 | Two-way binding des formulaires |
22
+
23
+ ---
24
+
25
+ ## StateProxy.js
26
+
27
+ Wraps le state dans un Proxy récursif pour détecter les mutations à n'importe quelle profondeur.
28
+
29
+ ### Fonctionnement
30
+
31
+ 1. Chaque objet/array est wrappé dans un Proxy
32
+ 2. Le trap `set` intercepte les mutations et appelle `_triggerRender()`
33
+ 3. Les méthodes d'array (push, pop, splice, etc.) sont wrappées
34
+ 4. Un WeakMap évite le double-wrapping
35
+
36
+ ### Code clé
37
+
38
+ ```javascript
39
+ const handler = {
40
+ set(target, property, value) {
41
+ target[property] = wrap(value); // Wrap récursif
42
+ component._triggerRender(); // Déclenche le render
43
+ return true;
44
+ }
45
+ };
46
+ ```
47
+
48
+ ### Mutations supportées
49
+
50
+ ```javascript
51
+ this.state.count = 5; // Niveau 1
52
+ this.state.user.name = 'John'; // Niveau 2
53
+ this.state.user.address.city = 'Paris'; // Niveau 3+
54
+ this.state.items.push({ id: 1 }); // Array methods
55
+ this.state.items[0].name = 'Updated'; // Array item mutation
56
+ ```
57
+
58
+ ---
59
+
60
+ ## DerivedCache.js
61
+
62
+ Memoization des getters avec tracking automatique des dépendances.
63
+
64
+ ### Fonctionnement
65
+
66
+ 1. Au premier appel d'un getter, `_isTracking = true`
67
+ 2. Chaque accès à `this.props.x` ou `this.state.y` est enregistré
68
+ 3. Le résultat est mis en cache avec ses dépendances
69
+ 4. Aux renders suivants, recalcul uniquement si les dépendances ont changé
70
+
71
+ ### Code clé
72
+
73
+ ```javascript
74
+ memoize(key, fn, deps, context, currentValue) {
75
+ const cached = this._cache.get(key);
76
+
77
+ if (cached && !this._depsChanged(cached.deps, deps, context)) {
78
+ return cached.value; // Cache hit
79
+ }
80
+
81
+ this._cache.set(key, { value: currentValue, deps });
82
+ return currentValue;
83
+ }
84
+ ```
85
+
86
+ ---
87
+
88
+ ## EventBinder.js
89
+
90
+ Gestion optimisée des événements avec WeakMap.
91
+
92
+ ### Fonctionnement
93
+
94
+ 1. `WeakMap<Element, Map<eventType, handler>>` stocke les listeners
95
+ 2. Au render, vérifie si le listener existe déjà
96
+ 3. Si l'élément est préservé par DiffDOM, le listener est réutilisé
97
+ 4. Si l'élément est remplacé, nouveau listener créé
98
+ 5. Les éléments supprimés sont garbage collectés automatiquement
99
+
100
+ ### Performance
101
+
102
+ - O(1) lookup pour vérifier si un listener existe
103
+ - Pas de rebinding si l'élément n'a pas changé
104
+ - Pas de memory leaks grâce à WeakMap
105
+
106
+ ---
107
+
108
+ ## FormHandler.js
109
+
110
+ Synchronisation automatique des champs de formulaire avec `this.state.form`.
111
+
112
+ ### Activation
113
+
114
+ Le FormHandler s'active si `this.props.form` existe dans le constructeur.
115
+
116
+ ### Fonctionnement
117
+
118
+ 1. `initForm()` : Copie les valeurs initiales dans un objet partagé (`window.__igo2_form`)
119
+ 2. `bind()` : Attache les listeners sur les inputs
120
+ 3. `handleInput()` : Met à jour `this.state.form` à chaque changement
121
+
122
+ ### Valeurs toujours stockées comme strings
123
+
124
+ Les valeurs du formulaire sont **toujours stockées comme strings** (sauf checkboxes → boolean).
125
+ Cela garantit la cohérence entre le SSR (où `req.query` retourne des strings) et le client.
126
+
127
+ **Les getters doivent convertir explicitement les types quand nécessaire :**
128
+
129
+ ```javascript
130
+ get selected_product() {
131
+ const productId = Number(this.state.form?.product_id);
132
+ return this.props.products?.find(p => p.id === productId);
133
+ }
134
+ ```
135
+
136
+ ### Types supportés
137
+
138
+ | Attribut | Valeur stockée |
139
+ |----------|----------------|
140
+ | `type="text"` | String |
141
+ | `type="number"` | String (convertir avec `Number()` si besoin) |
142
+ | `type="checkbox"` | Boolean ou Array de strings |
143
+ | `name="x[]"` | Array de strings |
144
+ | `name="x[0][]"` | Nested array de strings |
145
+
146
+ ---
147
+
148
+ ## Igo2Component.js
149
+
150
+ Classe de base orchestrant tous les modules.
151
+
152
+ ### Lifecycle
153
+
154
+ ```
155
+ constructor()
156
+
157
+ init()
158
+
159
+ loadTemplate()
160
+
161
+ render() ←──────────────┐
162
+ ↓ │
163
+ beforeRender() │
164
+ ↓ │
165
+ _computeGettersAsDerived()
166
+ ↓ │
167
+ dust.render() │
168
+ ↓ │
169
+ DiffDOM.apply() │
170
+ ↓ │
171
+ _syncChildProps() │
172
+ ↓ │
173
+ _bindEvents() │
174
+ ↓ │
175
+ _mountChildComponents() │
176
+ ↓ │
177
+ afterRender() │
178
+ ↓ │
179
+ [state mutation] ───────┘
180
+ ```
181
+
182
+ ### Render optimization
183
+
184
+ Les renders sont synchronisés avec le browser paint :
185
+
186
+ ```javascript
187
+ _triggerRender() {
188
+ if (this._renderFrame) {
189
+ cancelAnimationFrame(this._renderFrame);
190
+ }
191
+ this._renderFrame = requestAnimationFrame(() => this.render());
192
+ }
193
+ ```
194
+
195
+ ### Props hydration
196
+
197
+ Les props locales sont désérialisées avec `devalue` :
198
+
199
+ ```javascript
200
+ if (this.element.dataset.props) {
201
+ const hydrate = new Function('return ' + this.element.dataset.props);
202
+ localProps = hydrate();
203
+ }
204
+ this._props = { ...globalProps, ...localProps };
205
+ ```
206
+
207
+ ### Child components
208
+
209
+ Les composants enfants sont :
210
+ 1. Préservés par DiffDOM (leurs attributes peuvent être mis à jour)
211
+ 2. Montés automatiquement après le render parent
212
+ 3. Synchronisés via `_syncChildProps()` quand leurs data-props changent
213
+
214
+ ### Cleanup
215
+
216
+ ```javascript
217
+ async destroy() {
218
+ cancelAnimationFrame(this._renderFrame);
219
+ this._eventBinder.unbind();
220
+ this._formHandler?.unbind();
221
+ this._derivedCache.clear();
222
+ this.element.__igoInstance = null;
223
+ // ... null toutes les références
224
+ }
225
+ ```
226
+
227
+ ---
228
+
229
+ ## DiffDOM
230
+
231
+ Bibliothèque externe pour la réconciliation DOM.
232
+
233
+ ### Usage
234
+
235
+ ```javascript
236
+ const diff = diffDom.diff(this.element, newElement);
237
+ diffDom.apply(this.element, filteredDiff);
238
+ ```
239
+
240
+ ### Filtrage des child components
241
+
242
+ Les diffs touchant des composants enfants sont filtrés pour préserver leur état :
243
+
244
+ ```javascript
245
+ const filteredDiff = diff.filter(d => {
246
+ // Toujours autoriser les modifications d'attributs (data-props sync)
247
+ if (d.action === 'modifyAttribute') return true;
248
+
249
+ // Filtrer les diffs qui touchent des [data-component]
250
+ // ...
251
+ });
252
+ ```
253
+
254
+ ---
255
+
256
+ ## Templates Dust
257
+
258
+ Les templates reçoivent un contexte fusionné :
259
+
260
+ ```javascript
261
+ const context = {
262
+ ...this._props, // Props serveur
263
+ ...this._state, // State réactif
264
+ ...this._derivedValues // Getters calculés
265
+ };
266
+ ```
267
+
268
+ ### Helpers disponibles
269
+
270
+ - `{@json value=x /}` : Sérialise en JSON
271
+ - `{@devalue value=x /}` : Sérialise avec devalue (préserve les types)
272
+ - `{@selected key=a value=b /}` : Attribut selected conditionnel
273
+ - `{@disabled key=a value=b /}` : Attribut disabled conditionnel
@@ -0,0 +1,409 @@
1
+ // Isomorphic imports (safe for Node.js)
2
+ const DerivedCache = require('./DerivedCache');
3
+ const StateProxy = require('./StateProxy');
4
+
5
+ // Browser-only imports are loaded lazily in constructor
6
+
7
+ // Detect server-side rendering
8
+ const isServer = typeof window === 'undefined';
9
+
10
+ // Browser-only dependencies (lazy loaded once)
11
+ let _browserDeps = null;
12
+ const getBrowserDeps = () => {
13
+ if (!_browserDeps) {
14
+ _browserDeps = {
15
+ DiffDOM: require('diff-dom').DiffDOM,
16
+ EventBinder: require('./EventBinder'),
17
+ Templates: require('./dust/Templates'),
18
+ FormHandler: require('./FormHandler'),
19
+ };
20
+ }
21
+ return _browserDeps;
22
+ };
23
+
24
+ class Igo2Component {
25
+ // Component registry for auto-discovery
26
+ static _registry = {};
27
+
28
+
29
+ // Register components for auto-initialization
30
+ static register(components) {
31
+ Object.assign(this._registry, components);
32
+ }
33
+
34
+ // Mount all registered components found on the page
35
+ static mountAll() {
36
+ document.querySelectorAll('[data-component]').forEach(element => {
37
+ const componentName = element.dataset.component;
38
+ const ComponentClass = this._registry[componentName];
39
+
40
+ if (ComponentClass) {
41
+ if (element.__igoInstance) {
42
+ console.warn(`Component "${componentName}" already mounted on`, element);
43
+ return;
44
+ }
45
+ new ComponentClass(element);
46
+ } else {
47
+ console.warn(`Component "${componentName}" not registered`);
48
+ }
49
+ });
50
+ }
51
+
52
+ // Server-side rendering: compute derived values (getters) for Dust templates
53
+ static ssr(props) {
54
+ const instance = new this(null, null, props);
55
+
56
+ const derived = {};
57
+ const descriptors = Object.getOwnPropertyDescriptors(this.prototype);
58
+
59
+ for (const [key, desc] of Object.entries(descriptors)) {
60
+ if (!desc.get || key.startsWith('_') || key === 'rawState' || key === 'events') {
61
+ continue;
62
+ }
63
+ try {
64
+ derived[key] = desc.get.call(instance);
65
+ } catch (e) {
66
+ // Getter may access DOM or throw → log and skip
67
+ console.error(`SSR getter "${key}" error:`, e.message);
68
+ }
69
+ }
70
+
71
+ return derived;
72
+ }
73
+
74
+ constructor(element, template, props) {
75
+
76
+ this.template = template;
77
+
78
+ // Browser-only setup
79
+ if (!isServer) {
80
+ const deps = getBrowserDeps();
81
+
82
+ this.element = element;
83
+ this.element.__igoInstance = this;
84
+ this._dustTemplateFn = null;
85
+ this._eventBinder = new deps.EventBinder();
86
+ this._derivedCache = new DerivedCache();
87
+ this._isInitialized = false;
88
+ this._renderFrame = null;
89
+ this._diffDom = new deps.DiffDOM();
90
+ }
91
+
92
+ // Default events array (only if not defined as getter in subclass)
93
+ if (!Object.getOwnPropertyDescriptor(Object.getPrototypeOf(this), 'events')?.get) {
94
+ this.events = [];
95
+ }
96
+
97
+ // Auto-tracking system for smart dependencies
98
+ this._isTracking = false;
99
+ this._trackedDeps = [];
100
+
101
+ // Setup props, state, and derived with tracking proxies
102
+ let initialProps = {};
103
+
104
+ if (isServer) {
105
+ // SSR: use props passed to constructor
106
+ initialProps = props || {};
107
+ } else {
108
+ // Browser: hydrate from window and element
109
+ const globalProps = window.__signal_props || {};
110
+ let localProps = {};
111
+
112
+ // Hydrate local props from data-props attribute (serialized with devalue)
113
+ if (this.element.dataset.props) {
114
+ try {
115
+ // devalue produces a JS expression, so we need to evaluate it
116
+ // new Function is safer than eval because it creates a local scope
117
+ const hydrate = new Function('return ' + this.element.dataset.props);
118
+ localProps = hydrate();
119
+ } catch (e) {
120
+ console.error('Failed to parse data-props for component', this.element, e);
121
+ }
122
+ }
123
+
124
+ initialProps = { ...globalProps, ...localProps };
125
+ }
126
+
127
+ this._props = initialProps;
128
+ this._state = {};
129
+ this._derivedValues = {};
130
+
131
+ // Initialize form state from props (for SSR and browser)
132
+ if (initialProps.form) {
133
+ this._state.form = initialProps.form;
134
+ }
135
+
136
+ // Props (simple object in SSR, tracking proxy in browser)
137
+ if (isServer) {
138
+ this.props = this._props;
139
+ this.state = this._state;
140
+ } else {
141
+ this.props = this._createTrackingProxy(this._props, 'props');
142
+ this._stateProxy = new StateProxy(this, 'state');
143
+ this.state = this._stateProxy.create(this._state);
144
+ }
145
+
146
+ // Initialize component (browser only, async fire-and-forget)
147
+ if (!isServer) {
148
+ this.init();
149
+ }
150
+ }
151
+
152
+ // Create a tracking proxy for dependency detection
153
+ _createTrackingProxy(target, namespace) {
154
+ return new Proxy(target, {
155
+ get: (target, property) => {
156
+ if (this._isTracking) {
157
+ this._trackedDeps.push([namespace, property]);
158
+ }
159
+ return target[property];
160
+ }
161
+ });
162
+ }
163
+
164
+ // Expose raw state for internal use (bypasses Proxy, no auto-render)
165
+ get rawState() {
166
+ return this._state;
167
+ }
168
+
169
+ // Compute a derived value with automatic dependency tracking
170
+ _computeDerived(value, cacheKey) {
171
+ if (typeof value !== 'function') return value;
172
+
173
+ this._isTracking = true;
174
+ this._trackedDeps = [];
175
+ const boundFn = value.bind(this);
176
+ const computedValue = boundFn();
177
+ const deps = [...this._trackedDeps];
178
+ this._isTracking = false;
179
+
180
+ return this._derivedCache.memoize(cacheKey, boundFn, deps, this, computedValue);
181
+ }
182
+
183
+ // Initialize getters once (redefine on instance for lazy computation)
184
+ _initGetters() {
185
+ if (this._getterKeys) return; // Already initialized
186
+
187
+ const proto = Object.getPrototypeOf(this);
188
+ const descriptors = Object.getOwnPropertyDescriptors(proto);
189
+ const reserved = ['rawState', 'events'];
190
+
191
+ this._getterKeys = Object.keys(descriptors).filter(key => {
192
+ const desc = descriptors[key];
193
+ return desc.get && !reserved.includes(key) && !key.startsWith('_');
194
+ });
195
+
196
+ this._getterDescriptors = descriptors;
197
+
198
+ // Redefine getters on instance for lazy computation and tracking
199
+ this._getterKeys.forEach(key => {
200
+ Object.defineProperty(this, key, {
201
+ get: () => {
202
+ if (this._isTracking) {
203
+ this._trackedDeps.push(['derived', key]);
204
+ }
205
+ // Compute if not yet computed this cycle
206
+ if (!this._computedThisCycle?.has(key)) {
207
+ this._computeGetter(key);
208
+ }
209
+ return this._derivedValues[key];
210
+ },
211
+ configurable: true
212
+ });
213
+ });
214
+ }
215
+
216
+ // Compute a single getter
217
+ _computeGetter(key) {
218
+ this._computedThisCycle?.add(key);
219
+ const getterFn = this._getterDescriptors[key].get.bind(this);
220
+ this._derivedValues[key] = this._computeDerived(getterFn, key);
221
+ }
222
+
223
+ // Compute all getters for this render cycle
224
+ _computeGettersAsDerived() {
225
+ this._initGetters();
226
+ this._computedThisCycle = new Set();
227
+ this._getterKeys.forEach(key => this._computeGetter(key));
228
+ }
229
+
230
+ // Initialize component (called automatically by constructor)
231
+ // Can be overridden in subclasses for custom initialization
232
+ async init() {
233
+ const deps = getBrowserDeps();
234
+ this._dustTemplateFn = await deps.Templates.loadTemplate(this.template);
235
+ this._isInitialized = true;
236
+
237
+ // Initialize form handler if props.form exists
238
+ if (this.props.form) {
239
+ this._formHandler = new deps.FormHandler(this, this.props.form);
240
+ }
241
+
242
+ await this.render();
243
+ }
244
+
245
+ async render() {
246
+ try {
247
+ // beforeRender hook
248
+ await this.beforeRender?.();
249
+
250
+ // Calculate derived values (getters) with smart dependency tracking
251
+ this._computeGettersAsDerived();
252
+
253
+ // Merge props + state + derived for template context (flat)
254
+ const context = { ...this._props, ...this._state, ...this._derivedValues };
255
+ const html = await this._dustTemplateFn(context, window.__signal.IgoDustUtils, null);
256
+
257
+ const tempElement = document.createElement('div');
258
+ tempElement.innerHTML = html;
259
+
260
+ const diff = this._diffDom.diff(this.element, tempElement.firstElementChild);
261
+ const filteredDiff = this._filterChildComponentDiffs(diff);
262
+ this._diffDom.apply(this.element, filteredDiff);
263
+
264
+ // Sync props for child components (after DiffDOM updated data-props)
265
+ this._syncChildProps();
266
+
267
+ this._bindEvents();
268
+ this._mountChildComponents();
269
+ await this.afterRender();
270
+
271
+ } catch (error) {
272
+ console.error('SignalComponent render failed:', error);
273
+ await this.onError?.(error);
274
+ }
275
+ }
276
+
277
+ // Filter diffs that touch child components (preserve their state)
278
+ // Allow attribute modifications (like data-props) to pass through
279
+ _filterChildComponentDiffs(diff) {
280
+ return diff.filter(d => {
281
+ if (d.action === 'modifyAttribute' || d.action === 'addAttribute' || d.action === 'removeAttribute') {
282
+ return true;
283
+ }
284
+ let el = this.element;
285
+ for (const step of d.route || []) {
286
+ if (step === 'childNodes') continue;
287
+ if (typeof step === 'number') {
288
+ el = el?.childNodes?.[step];
289
+ if (el?.nodeType === 1 && el.hasAttribute?.('data-component') && el !== this.element) {
290
+ return false;
291
+ }
292
+ }
293
+ }
294
+ return true;
295
+ });
296
+ }
297
+
298
+ _bindEvents() {
299
+ this._formHandler?.unbind();
300
+ this._eventBinder.bind(this.element, this.events, this);
301
+ this._formHandler?.bind();
302
+ }
303
+
304
+ _mountChildComponents() {
305
+ // Mount any child components that were added during render
306
+ // Use global mountElement from signal/index.js
307
+ const mountElement = window.__signal?.mountElement;
308
+ if (!mountElement) {
309
+ return;
310
+ }
311
+
312
+ this.element.querySelectorAll('[data-component]').forEach(childElement => {
313
+ if (childElement === this.element) return;
314
+ if (childElement.__igoInstance) return;
315
+ mountElement(childElement);
316
+ });
317
+ }
318
+
319
+ _triggerRender() {
320
+ if (!this._isInitialized) {
321
+ return;
322
+ }
323
+ // Cancel any pending render
324
+ if (this._renderFrame) {
325
+ cancelAnimationFrame(this._renderFrame);
326
+ }
327
+ // Schedule render synchronized with browser paint
328
+ this._renderFrame = requestAnimationFrame(() => this.render());
329
+ }
330
+
331
+ // Sync props from parent (called after parent render)
332
+ _syncProps() {
333
+ if (!this.element?.dataset.props) {
334
+ return;
335
+ }
336
+
337
+ try {
338
+ const hydrate = new Function('return ' + this.element.dataset.props);
339
+ const newLocalProps = hydrate();
340
+
341
+ // Check if any prop changed
342
+ let hasChanges = false;
343
+ for (const key in newLocalProps) {
344
+ if (this._props[key] !== newLocalProps[key]) {
345
+ hasChanges = true;
346
+ break;
347
+ }
348
+ }
349
+
350
+ if (hasChanges) {
351
+ // Update _props with new local values
352
+ Object.assign(this._props, newLocalProps);
353
+ // Re-render (getters that depend on props will return new values)
354
+ this._triggerRender();
355
+ }
356
+ } catch (e) {
357
+ console.error('Failed to sync props', e);
358
+ }
359
+ }
360
+
361
+ // Sync props for all child components
362
+ _syncChildProps() {
363
+ this.element.querySelectorAll('[data-component]').forEach(childElement => {
364
+ if (childElement === this.element) return;
365
+ if (childElement.__igoInstance) {
366
+ childElement.__igoInstance._syncProps();
367
+ }
368
+ });
369
+ }
370
+
371
+ // Cleanup component (unbind listeners, clear timers, remove references)
372
+ async destroy() {
373
+ // Cancel any pending render
374
+ if (this._renderFrame) {
375
+ cancelAnimationFrame(this._renderFrame);
376
+ }
377
+
378
+ // Unbind all event listeners
379
+ this._eventBinder.unbind();
380
+
381
+ // Unbind form handler
382
+ this._formHandler?.unbind();
383
+ this._formHandler = null;
384
+
385
+ // Clear derived cache
386
+ this._derivedCache.clear();
387
+
388
+ // Clear references to help garbage collection
389
+ if (this.element) {
390
+ this.element.__igoInstance = null;
391
+ }
392
+ this.element = null;
393
+ this._dustTemplateFn = null;
394
+ this._eventBinder = null;
395
+ this._derivedCache = null;
396
+ this._stateProxy = null;
397
+ this._state = {};
398
+ this._derivedValues = {};
399
+ this._trackedDeps = [];
400
+ }
401
+
402
+ // Lifecycle hooks (can be overridden in subclasses)
403
+ async beforeRender() { }
404
+ async afterRender() { }
405
+ async onError(error) { }
406
+
407
+ }
408
+
409
+ module.exports = Igo2Component;