@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 +203 -0
- package/index.js +24 -0
- package/package.json +30 -0
- package/src/client/DerivedCache.js +63 -0
- package/src/client/EventBinder.js +135 -0
- package/src/client/FormHandler.js +94 -0
- package/src/client/README.md +273 -0
- package/src/client/SignalComponent.js +409 -0
- package/src/client/StateProxy.js +74 -0
- package/src/client/dust/Templates.js +26 -0
- package/src/client/dust/Utils.js +124 -0
- package/src/client/dust/i18n.js +22 -0
- package/src/client/index.js +72 -0
- package/src/server/SerializeUtils.js +70 -0
- package/src/server/SignalController.js +94 -0
- package/src/server/index.js +17 -0
- package/src/shared/serialize.js +35 -0
- package/test/client/DerivedCacheTest.js +44 -0
- package/test/client/SignalComponentTest.js +59 -0
- package/test/client/StateProxyTest.js +59 -0
- package/test/server/SerializeUtilsTest.js +35 -0
|
@@ -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;
|