@coherent.js/client 1.0.0-beta.2
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/LICENSE +21 -0
- package/README.md +105 -0
- package/dist/client/hmr.d.ts +1 -0
- package/dist/client/hmr.d.ts.map +1 -0
- package/dist/client/hmr.js +107 -0
- package/dist/client/hmr.js.map +1 -0
- package/dist/client/hydration.d.ts +55 -0
- package/dist/client/hydration.d.ts.map +1 -0
- package/dist/client/hydration.js +1593 -0
- package/dist/client/hydration.js.map +1 -0
- package/dist/index.js +964 -0
- package/dist/index.js.map +7 -0
- package/package.json +45 -0
- package/src/hydration.js +1791 -0
- package/types/index.d.ts +498 -0
package/src/hydration.js
ADDED
|
@@ -0,0 +1,1791 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side hydration utilities for Coherent.js
|
|
3
|
+
*
|
|
4
|
+
* This module provides utilities for hydrating server-rendered HTML
|
|
5
|
+
* with client-side interactivity.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Store for component instances
|
|
9
|
+
const componentInstances = new WeakMap();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extract initial state from DOM element data attributes
|
|
13
|
+
*
|
|
14
|
+
* @param {HTMLElement} element - The DOM element
|
|
15
|
+
* @param {Object} options - Hydration options
|
|
16
|
+
* @returns {Object|null} The initial state or null
|
|
17
|
+
*/
|
|
18
|
+
function extractInitialState(element, options = {}) {
|
|
19
|
+
// Check if we're in a browser environment
|
|
20
|
+
if (typeof window === 'undefined') {
|
|
21
|
+
return options.initialState || null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Check if element has getAttribute method
|
|
25
|
+
if (!element || typeof element.getAttribute !== 'function') {
|
|
26
|
+
return options.initialState || null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
// Look for data-coherent-state attribute
|
|
31
|
+
const stateAttr = element.getAttribute('data-coherent-state');
|
|
32
|
+
if (stateAttr) {
|
|
33
|
+
return JSON.parse(stateAttr);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Look for specific state attributes
|
|
37
|
+
const state = {};
|
|
38
|
+
let hasState = false;
|
|
39
|
+
|
|
40
|
+
// Extract common state patterns
|
|
41
|
+
const countAttr = element.getAttribute('data-count');
|
|
42
|
+
if (countAttr !== null) {
|
|
43
|
+
state.count = parseInt(countAttr, 10) || 0;
|
|
44
|
+
hasState = true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const stepAttr = element.getAttribute('data-step');
|
|
48
|
+
if (stepAttr !== null) {
|
|
49
|
+
state.step = parseInt(stepAttr, 10) || 1;
|
|
50
|
+
hasState = true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const todosAttr = element.getAttribute('data-todos');
|
|
54
|
+
if (todosAttr) {
|
|
55
|
+
state.todos = JSON.parse(todosAttr);
|
|
56
|
+
hasState = true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const valueAttr = element.getAttribute('data-value');
|
|
60
|
+
if (valueAttr !== null) {
|
|
61
|
+
state.value = valueAttr;
|
|
62
|
+
hasState = true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check for initial props in options
|
|
66
|
+
if (options.initialState) {
|
|
67
|
+
return { ...options.initialState, ...state };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return hasState ? state : null;
|
|
71
|
+
} catch (_error) {
|
|
72
|
+
console.warn('Error extracting initial state:', _error);
|
|
73
|
+
return options.initialState || null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Hydrate a DOM element with a Coherent component
|
|
79
|
+
*
|
|
80
|
+
* @param {HTMLElement} element - The DOM element to hydrate
|
|
81
|
+
* @param {Function} component - The Coherent component function
|
|
82
|
+
* @param {Object} props - The props to pass to the component
|
|
83
|
+
* @param {Object} options - Hydration options
|
|
84
|
+
* @returns {Object} The hydrated component instance
|
|
85
|
+
*/
|
|
86
|
+
function hydrate(element, component, props = {}, options = {}) {
|
|
87
|
+
// Hydration process initiated
|
|
88
|
+
|
|
89
|
+
if (typeof window === 'undefined') {
|
|
90
|
+
console.warn('Hydration can only be performed in a browser environment');
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Validate component
|
|
95
|
+
if (typeof component !== 'function') {
|
|
96
|
+
console.error('Hydrate error: component must be a function, received:', typeof component);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Check if element is already hydrated
|
|
101
|
+
if (componentInstances.has(element)) {
|
|
102
|
+
const existingInstance = componentInstances.get(element);
|
|
103
|
+
return existingInstance;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Extract initial state from data attributes if available
|
|
107
|
+
const initialState = extractInitialState(element, options);
|
|
108
|
+
|
|
109
|
+
// Create component instance with state management
|
|
110
|
+
const instance = {
|
|
111
|
+
element,
|
|
112
|
+
component,
|
|
113
|
+
props: {...props},
|
|
114
|
+
state: initialState,
|
|
115
|
+
isHydrated: true,
|
|
116
|
+
eventListeners: [],
|
|
117
|
+
options: {...options},
|
|
118
|
+
previousVirtualElement: null,
|
|
119
|
+
|
|
120
|
+
// Update method for re-rendering
|
|
121
|
+
update(newProps) {
|
|
122
|
+
this.props = { ...this.props, ...newProps };
|
|
123
|
+
this.rerender();
|
|
124
|
+
return this; // Return instance for chaining
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
// Re-render the component with current state
|
|
128
|
+
rerender() {
|
|
129
|
+
try {
|
|
130
|
+
// Always use the fallback patching method to preserve hydration
|
|
131
|
+
this.fallbackRerender();
|
|
132
|
+
} catch (_error) {
|
|
133
|
+
console.error('Error during component re-render:', _error);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
// Fallback re-render method using existing patching
|
|
138
|
+
fallbackRerender() {
|
|
139
|
+
try {
|
|
140
|
+
// Call the component function with current props and state
|
|
141
|
+
const componentProps = { ...this.props, ...(this.state || {}) };
|
|
142
|
+
|
|
143
|
+
// Check if component is a function before calling
|
|
144
|
+
if (typeof this.component !== 'function') {
|
|
145
|
+
console.error('Component is not a function:', this.component);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const newVirtualElement = this.component(componentProps);
|
|
150
|
+
|
|
151
|
+
// Store the previous virtual element for comparison
|
|
152
|
+
if (!this.previousVirtualElement) {
|
|
153
|
+
this.previousVirtualElement = this.virtualElementFromDOM(this.element);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Perform intelligent DOM diffing and patching
|
|
157
|
+
this.patchDOM(this.element, this.previousVirtualElement, newVirtualElement);
|
|
158
|
+
|
|
159
|
+
// Re-attach event listeners for input elements only
|
|
160
|
+
attachFunctionEventListeners(this.element, newVirtualElement, this, { inputsOnly: true });
|
|
161
|
+
|
|
162
|
+
// Store the new virtual element for next comparison
|
|
163
|
+
this.previousVirtualElement = newVirtualElement;
|
|
164
|
+
|
|
165
|
+
// Component re-rendered successfully with fallback
|
|
166
|
+
} catch (_error) {
|
|
167
|
+
console.error('Error during component re-render (fallback):', _error);
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
// Create virtual element representation from existing DOM
|
|
172
|
+
virtualElementFromDOM(domElement) {
|
|
173
|
+
// Check if we're in a browser environment
|
|
174
|
+
if (typeof window === 'undefined' || typeof Node === 'undefined') {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (domElement.nodeType === Node.TEXT_NODE) {
|
|
179
|
+
return domElement.textContent;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (domElement.nodeType !== Node.ELEMENT_NODE) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const tagName = domElement.tagName.toLowerCase();
|
|
187
|
+
const props = {};
|
|
188
|
+
const children = [];
|
|
189
|
+
|
|
190
|
+
// Extract attributes
|
|
191
|
+
if (domElement.attributes) {
|
|
192
|
+
Array.from(domElement.attributes).forEach(attr => {
|
|
193
|
+
const name = attr.name === 'class' ? 'className' : attr.name;
|
|
194
|
+
props[name] = attr.value;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Extract children
|
|
199
|
+
if (domElement.childNodes) {
|
|
200
|
+
Array.from(domElement.childNodes).forEach(child => {
|
|
201
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
202
|
+
const text = child.textContent.trim();
|
|
203
|
+
if (text) children.push(text);
|
|
204
|
+
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
205
|
+
const childVNode = this.virtualElementFromDOM(child);
|
|
206
|
+
if (childVNode) children.push(childVNode);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (children.length > 0) {
|
|
212
|
+
props.children = children;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { [tagName]: props };
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
// Intelligent DOM patching with minimal changes
|
|
219
|
+
patchDOM(domElement, oldVNode, newVNode) {
|
|
220
|
+
// Handle text nodes
|
|
221
|
+
if (typeof newVNode === 'string' || typeof newVNode === 'number') {
|
|
222
|
+
const newText = String(newVNode);
|
|
223
|
+
// Check if we're in a browser environment
|
|
224
|
+
if (typeof window === 'undefined' || typeof Node === 'undefined' || typeof document === 'undefined') {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (domElement.nodeType === Node.TEXT_NODE) {
|
|
229
|
+
if (domElement.textContent !== newText) {
|
|
230
|
+
domElement.textContent = newText;
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
// Replace element with text node
|
|
234
|
+
const textNode = document.createTextNode(newText);
|
|
235
|
+
if (domElement.parentNode) {
|
|
236
|
+
domElement.parentNode.replaceChild(textNode, domElement);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Handle null/undefined
|
|
243
|
+
if (!newVNode) {
|
|
244
|
+
domElement.remove();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Handle arrays
|
|
249
|
+
if (Array.isArray(newVNode)) {
|
|
250
|
+
// This shouldn't happen at the root level, but handle gracefully
|
|
251
|
+
console.warn('Array virtual node at root level');
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Handle element nodes
|
|
256
|
+
const newTagName = Object.keys(newVNode)[0];
|
|
257
|
+
|
|
258
|
+
// Check if tag name changed
|
|
259
|
+
if (domElement.tagName.toLowerCase() !== newTagName.toLowerCase()) {
|
|
260
|
+
// Need to replace the entire element
|
|
261
|
+
// Check if we're in a browser environment
|
|
262
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const newElement = this.createDOMElement(newVNode);
|
|
267
|
+
if (domElement.parentNode) {
|
|
268
|
+
domElement.parentNode.replaceChild(newElement, domElement);
|
|
269
|
+
}
|
|
270
|
+
attachEventListeners(newElement, this);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Update attributes
|
|
275
|
+
this.patchAttributes(domElement, oldVNode, newVNode);
|
|
276
|
+
|
|
277
|
+
// Update children
|
|
278
|
+
this.patchChildren(domElement, oldVNode, newVNode);
|
|
279
|
+
|
|
280
|
+
// Re-attach event listeners if needed
|
|
281
|
+
attachEventListeners(domElement, this);
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
// Patch element attributes efficiently
|
|
285
|
+
patchAttributes(domElement, oldVNode, newVNode) {
|
|
286
|
+
// Check if we're in a browser environment
|
|
287
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Check if domElement has required methods
|
|
292
|
+
if (!domElement || typeof domElement.setAttribute !== 'function' || typeof domElement.removeAttribute !== 'function') {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const oldTagName = oldVNode ? Object.keys(oldVNode)[0] : null;
|
|
297
|
+
const newTagName = Object.keys(newVNode)[0];
|
|
298
|
+
const oldProps = oldVNode && oldTagName ? (oldVNode[oldTagName] || {}) : {};
|
|
299
|
+
const newProps = newVNode[newTagName] || {};
|
|
300
|
+
|
|
301
|
+
// Remove old attributes that are no longer present
|
|
302
|
+
Object.keys(oldProps).forEach(key => {
|
|
303
|
+
if (key === 'children' || key === 'text') return;
|
|
304
|
+
if (!(key in newProps)) {
|
|
305
|
+
const attrName = key === 'className' ? 'class' : key;
|
|
306
|
+
domElement.removeAttribute(attrName);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Add or update new attributes
|
|
311
|
+
Object.keys(newProps).forEach(key => {
|
|
312
|
+
if (key === 'children' || key === 'text') return;
|
|
313
|
+
const newValue = newProps[key];
|
|
314
|
+
const oldValue = oldProps[key];
|
|
315
|
+
|
|
316
|
+
if (newValue !== oldValue) {
|
|
317
|
+
const attrName = key === 'className' ? 'class' : key;
|
|
318
|
+
|
|
319
|
+
if (newValue === true) {
|
|
320
|
+
domElement.setAttribute(attrName, '');
|
|
321
|
+
} else if (newValue === false || newValue === null) {
|
|
322
|
+
domElement.removeAttribute(attrName);
|
|
323
|
+
} else {
|
|
324
|
+
domElement.setAttribute(attrName, String(newValue));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
// Patch children with intelligent list diffing
|
|
331
|
+
patchChildren(domElement, oldVNode, newVNode) {
|
|
332
|
+
// Check if we're in a browser environment
|
|
333
|
+
if (typeof window === 'undefined' || typeof Node === 'undefined' || typeof document === 'undefined') {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Check if domElement has required methods
|
|
338
|
+
if (!domElement || typeof domElement.childNodes === 'undefined' || typeof domElement.appendChild !== 'function') {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const oldTagName = oldVNode ? Object.keys(oldVNode)[0] : null;
|
|
343
|
+
const newTagName = Object.keys(newVNode)[0];
|
|
344
|
+
const oldProps = oldVNode && oldTagName ? (oldVNode[oldTagName] || {}) : {};
|
|
345
|
+
const newProps = newVNode[newTagName] || {};
|
|
346
|
+
|
|
347
|
+
// Extract children arrays
|
|
348
|
+
let oldChildren = [];
|
|
349
|
+
let newChildren = [];
|
|
350
|
+
|
|
351
|
+
// Handle old children
|
|
352
|
+
if (oldProps.children) {
|
|
353
|
+
oldChildren = Array.isArray(oldProps.children) ? oldProps.children : [oldProps.children];
|
|
354
|
+
} else if (oldProps.text) {
|
|
355
|
+
oldChildren = [oldProps.text];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Handle new children
|
|
359
|
+
if (newProps.children) {
|
|
360
|
+
newChildren = Array.isArray(newProps.children) ? newProps.children : [newProps.children];
|
|
361
|
+
} else if (newProps.text) {
|
|
362
|
+
newChildren = [newProps.text];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Get current DOM children (excluding text nodes that are just whitespace)
|
|
366
|
+
// Check if Array.from is available and domElement.childNodes is iterable
|
|
367
|
+
let domChildren = [];
|
|
368
|
+
if (typeof Array.from === 'function' && domElement.childNodes) {
|
|
369
|
+
try {
|
|
370
|
+
domChildren = Array.from(domElement.childNodes).filter(node => {
|
|
371
|
+
return node.nodeType === Node.ELEMENT_NODE ||
|
|
372
|
+
(node.nodeType === Node.TEXT_NODE && node.textContent && node.textContent.trim());
|
|
373
|
+
});
|
|
374
|
+
} catch (_error) {
|
|
375
|
+
// Fallback to empty array if Array.from fails
|
|
376
|
+
console.warn('Failed to convert childNodes to array:', _error);
|
|
377
|
+
domChildren = [];
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Simple diffing algorithm - can be improved with key-based diffing
|
|
382
|
+
const maxLength = Math.max(oldChildren.length, newChildren.length, domChildren.length);
|
|
383
|
+
|
|
384
|
+
for (let i = 0; i < maxLength; i++) {
|
|
385
|
+
const oldChild = oldChildren[i];
|
|
386
|
+
const newChild = newChildren[i];
|
|
387
|
+
const domChild = domChildren[i];
|
|
388
|
+
|
|
389
|
+
if (newChild === undefined) {
|
|
390
|
+
// Remove extra DOM children
|
|
391
|
+
if (domChild && typeof domChild.remove === 'function') {
|
|
392
|
+
domChild.remove();
|
|
393
|
+
}
|
|
394
|
+
} else if (domChild === undefined) {
|
|
395
|
+
// Add new DOM children
|
|
396
|
+
const newElement = this.createDOMElement(newChild);
|
|
397
|
+
if (newElement) {
|
|
398
|
+
domElement.appendChild(newElement);
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
// Patch existing child
|
|
402
|
+
this.patchDOM(domChild, oldChild, newChild);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
|
|
407
|
+
// Create DOM element from virtual element
|
|
408
|
+
createDOMElement(vNode) {
|
|
409
|
+
if (typeof vNode === 'string' || typeof vNode === 'number') {
|
|
410
|
+
return document.createTextNode(String(vNode));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (!vNode || typeof vNode !== 'object') {
|
|
414
|
+
return document.createTextNode('');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (Array.isArray(vNode)) {
|
|
418
|
+
const fragment = document.createDocumentFragment();
|
|
419
|
+
vNode.forEach(child => {
|
|
420
|
+
fragment.appendChild(this.createDOMElement(child));
|
|
421
|
+
});
|
|
422
|
+
return fragment;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const tagName = Object.keys(vNode)[0];
|
|
426
|
+
const props = vNode[tagName] || {};
|
|
427
|
+
const element = document.createElement(tagName);
|
|
428
|
+
|
|
429
|
+
// Set attributes
|
|
430
|
+
Object.keys(props).forEach(key => {
|
|
431
|
+
if (key === 'children' || key === 'text') return;
|
|
432
|
+
|
|
433
|
+
const value = props[key];
|
|
434
|
+
const attrName = key === 'className' ? 'class' : key;
|
|
435
|
+
|
|
436
|
+
if (value === true) {
|
|
437
|
+
element.setAttribute(attrName, '');
|
|
438
|
+
} else if (value !== false && value !== null) {
|
|
439
|
+
element.setAttribute(attrName, String(value));
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// Add children
|
|
444
|
+
if (props.children) {
|
|
445
|
+
const children = Array.isArray(props.children) ? props.children : [props.children];
|
|
446
|
+
children.forEach(child => {
|
|
447
|
+
element.appendChild(this.createDOMElement(child));
|
|
448
|
+
});
|
|
449
|
+
} else if (props.text) {
|
|
450
|
+
element.appendChild(document.createTextNode(String(props.text)));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return element;
|
|
454
|
+
},
|
|
455
|
+
|
|
456
|
+
// Render virtual element to HTML string
|
|
457
|
+
renderVirtualElement(element) {
|
|
458
|
+
if (typeof element === 'string' || typeof element === 'number') {
|
|
459
|
+
return String(element);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (!element || typeof element !== 'object') {
|
|
463
|
+
return '';
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Handle arrays of elements
|
|
467
|
+
if (Array.isArray(element)) {
|
|
468
|
+
return element.map(el => this.renderVirtualElement(el)).join('');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Handle Coherent.js object syntax
|
|
472
|
+
const tagName = Object.keys(element)[0];
|
|
473
|
+
const props = element[tagName];
|
|
474
|
+
|
|
475
|
+
if (!props || typeof props !== 'object') {
|
|
476
|
+
return `<${tagName}></${tagName}>`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Build attributes
|
|
480
|
+
let attributes = '';
|
|
481
|
+
const children = [];
|
|
482
|
+
|
|
483
|
+
Object.keys(props).forEach(key => {
|
|
484
|
+
if (key === 'children') {
|
|
485
|
+
if (Array.isArray(props.children)) {
|
|
486
|
+
children.push(...props.children);
|
|
487
|
+
} else {
|
|
488
|
+
children.push(props.children);
|
|
489
|
+
}
|
|
490
|
+
} else if (key === 'text') {
|
|
491
|
+
children.push(props.text);
|
|
492
|
+
} else {
|
|
493
|
+
const attrName = key === 'className' ? 'class' : key;
|
|
494
|
+
const value = props[key];
|
|
495
|
+
if (value === true) {
|
|
496
|
+
attributes += ` ${attrName}`;
|
|
497
|
+
} else if (value !== false && value !== null && value !== undefined) {
|
|
498
|
+
attributes += ` ${attrName}="${String(value).replace(/"/g, '"')}"`;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// Render children
|
|
504
|
+
const childrenHTML = children.map(child => this.renderVirtualElement(child)).join('');
|
|
505
|
+
|
|
506
|
+
// Check if it's a void element
|
|
507
|
+
const voidElements = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
508
|
+
|
|
509
|
+
if (voidElements.has(tagName.toLowerCase())) {
|
|
510
|
+
return `<${tagName}${attributes}>`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return `<${tagName}${attributes}>${childrenHTML}</${tagName}>`;
|
|
514
|
+
},
|
|
515
|
+
|
|
516
|
+
// Destroy the component and clean up
|
|
517
|
+
destroy() {
|
|
518
|
+
// Remove event listeners
|
|
519
|
+
this.eventListeners.forEach(({element, event, handler}) => {
|
|
520
|
+
if (element.removeEventListener) {
|
|
521
|
+
element.removeEventListener(event, handler);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// Clean up state
|
|
526
|
+
this.state = null;
|
|
527
|
+
this.isHydrated = false;
|
|
528
|
+
|
|
529
|
+
// Remove from instances map
|
|
530
|
+
componentInstances.delete(this.element);
|
|
531
|
+
|
|
532
|
+
// Component destroyed
|
|
533
|
+
},
|
|
534
|
+
|
|
535
|
+
// Set state (for components with state)
|
|
536
|
+
setState(newState) {
|
|
537
|
+
if (!this.state) {
|
|
538
|
+
this.state = {};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const oldState = {...this.state};
|
|
542
|
+
this.state = typeof newState === 'function' ?
|
|
543
|
+
{...this.state, ...newState(this.state)} :
|
|
544
|
+
{...this.state, ...newState};
|
|
545
|
+
|
|
546
|
+
// Trigger re-render
|
|
547
|
+
this.rerender();
|
|
548
|
+
|
|
549
|
+
// Call state change callback if exists
|
|
550
|
+
if (this.onStateChange) {
|
|
551
|
+
this.onStateChange(this.state, oldState);
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
|
|
555
|
+
// Add event listener that will be cleaned up on destroy
|
|
556
|
+
addEventListener(targetElement, event, handler) {
|
|
557
|
+
if (targetElement.addEventListener) {
|
|
558
|
+
targetElement.addEventListener(event, handler);
|
|
559
|
+
this.eventListeners.push({element: targetElement, event, handler});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
// Store instance
|
|
565
|
+
componentInstances.set(element, instance);
|
|
566
|
+
|
|
567
|
+
// Store the instance on the root element for event handler access
|
|
568
|
+
if (element && typeof element.setAttribute === 'function') {
|
|
569
|
+
element.__coherentInstance = instance;
|
|
570
|
+
// Also add a data attribute to identify this as a coherent component
|
|
571
|
+
if (!element.hasAttribute('data-coherent-component')) {
|
|
572
|
+
element.setAttribute('data-coherent-component', 'true');
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Re-execute component to get fresh virtual DOM with function handlers
|
|
577
|
+
// For withState components, we need to ensure the state management is properly initialized
|
|
578
|
+
const componentProps = { ...instance.props };
|
|
579
|
+
|
|
580
|
+
// If this is a withState component, initialize its state properly
|
|
581
|
+
if (instance.component.__stateContainer) {
|
|
582
|
+
// Initialize state container with hydrated state if available
|
|
583
|
+
if (instance.state) {
|
|
584
|
+
instance.component.__stateContainer.setState(instance.state);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Override the instance setState to use the state container
|
|
588
|
+
instance.setState = (newState) => {
|
|
589
|
+
// Update the state container
|
|
590
|
+
instance.component.__stateContainer.setState(newState);
|
|
591
|
+
|
|
592
|
+
// Update the instance state for consistency
|
|
593
|
+
const updatedState = instance.component.__stateContainer.getState();
|
|
594
|
+
instance.state = updatedState;
|
|
595
|
+
|
|
596
|
+
// Trigger re-render
|
|
597
|
+
instance.rerender();
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Execute component function to get fresh virtual DOM
|
|
602
|
+
const freshVirtualElement = instance.component(componentProps);
|
|
603
|
+
|
|
604
|
+
// Try to inspect the structure more carefully
|
|
605
|
+
if (freshVirtualElement && typeof freshVirtualElement === 'object') {
|
|
606
|
+
const tagName = Object.keys(freshVirtualElement)[0];
|
|
607
|
+
// Process root element and children
|
|
608
|
+
if (freshVirtualElement[tagName]) {
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Attach function-based event listeners from the fresh virtual DOM
|
|
613
|
+
attachFunctionEventListeners(element, freshVirtualElement, instance);
|
|
614
|
+
|
|
615
|
+
// Skip legacy event listeners to avoid conflicts with function handlers
|
|
616
|
+
// Skip legacy event attachment to prevent conflicts
|
|
617
|
+
|
|
618
|
+
// Component hydrated
|
|
619
|
+
|
|
620
|
+
return instance;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Global registry for event handlers
|
|
624
|
+
const eventRegistry = {};
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Register an event handler for later use
|
|
628
|
+
* @param {string} id - Unique identifier for the event handler
|
|
629
|
+
* @param {Function} handler - The event handler function
|
|
630
|
+
*/
|
|
631
|
+
export function registerEventHandler(id, handler) {
|
|
632
|
+
eventRegistry[id] = handler;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Global event handler that can be called from inline event attributes
|
|
637
|
+
* @param {string} eventId - The event handler ID
|
|
638
|
+
* @param {Element} element - The DOM element
|
|
639
|
+
* @param {Event} event - The event object
|
|
640
|
+
*/
|
|
641
|
+
if (typeof window !== 'undefined') {
|
|
642
|
+
// Initialize event registries if they don't exist
|
|
643
|
+
window.__coherentEventRegistry = window.__coherentEventRegistry || {};
|
|
644
|
+
window.__coherentActionRegistry = window.__coherentActionRegistry || {};
|
|
645
|
+
|
|
646
|
+
window.__coherentEventHandler = function(eventId, element, event) {
|
|
647
|
+
// Event handler called
|
|
648
|
+
|
|
649
|
+
// Try to get the function from the event registry first
|
|
650
|
+
let handlerFunc = window.__coherentEventRegistry[eventId];
|
|
651
|
+
|
|
652
|
+
// If not found in event registry, try action registry
|
|
653
|
+
if (!handlerFunc && window.__coherentActionRegistry[eventId]) {
|
|
654
|
+
handlerFunc = window.__coherentActionRegistry[eventId];
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (handlerFunc) {
|
|
658
|
+
// Try to find the component instance associated with this element
|
|
659
|
+
let componentElement = element;
|
|
660
|
+
while (componentElement && !componentElement.hasAttribute('data-coherent-component')) {
|
|
661
|
+
componentElement = componentElement.parentElement;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (componentElement && componentElement.__coherentInstance) {
|
|
665
|
+
// We found the component instance
|
|
666
|
+
const instance = componentElement.__coherentInstance;
|
|
667
|
+
const state = instance.state || {};
|
|
668
|
+
const setState = instance.setState ? instance.setState.bind(instance) : (() => {});
|
|
669
|
+
|
|
670
|
+
try {
|
|
671
|
+
// Call the handler function with the element as context and pass event, state, setState
|
|
672
|
+
handlerFunc.call(element, event, state, setState);
|
|
673
|
+
} catch (_error) {
|
|
674
|
+
console.warn(`Error executing coherent event handler:`, _error);
|
|
675
|
+
}
|
|
676
|
+
} else {
|
|
677
|
+
// Fallback: call the handler without component context
|
|
678
|
+
try {
|
|
679
|
+
handlerFunc.call(element, event);
|
|
680
|
+
} catch (_error) {
|
|
681
|
+
console.warn(`Error executing coherent event handler (no component context):`, _error);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
} else {
|
|
685
|
+
console.warn(`Event handler not found for ID: ${eventId}`);
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Updates DOM elements to reflect state changes using direct DOM manipulation.
|
|
692
|
+
* This function serves as the main entry point for synchronizing component state
|
|
693
|
+
* with the visual representation in the DOM.
|
|
694
|
+
*
|
|
695
|
+
* @param {HTMLElement} rootElement - The root component element containing the UI to update
|
|
696
|
+
* @param {Object} state - The new state object containing updated component data
|
|
697
|
+
* @since 0.1.2
|
|
698
|
+
*/
|
|
699
|
+
function updateDOMWithState(rootElement, state) {
|
|
700
|
+
if (!rootElement || !state) return;
|
|
701
|
+
|
|
702
|
+
// Use direct DOM updates to avoid breaking event handlers
|
|
703
|
+
updateDOMElementsDirectly(rootElement, state);
|
|
704
|
+
|
|
705
|
+
// Also update any dynamic content that needs to be re-rendered
|
|
706
|
+
updateDynamicContent(rootElement, state);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Simple virtual DOM to DOM rendering fallback
|
|
711
|
+
*
|
|
712
|
+
* @param {Object} vdom - Virtual DOM object
|
|
713
|
+
* @param {HTMLElement} container - Container element
|
|
714
|
+
*/
|
|
715
|
+
// eslint-disable-next-line no-unused-vars -- kept for future SSR fallback rendering
|
|
716
|
+
function renderVirtualDOMToElement(vdom, container) {
|
|
717
|
+
if (!vdom || !container) return;
|
|
718
|
+
|
|
719
|
+
// Handle different virtual DOM structures
|
|
720
|
+
if (typeof vdom === 'string') {
|
|
721
|
+
container.textContent = vdom;
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (typeof vdom === 'object') {
|
|
726
|
+
// Get the tag name (first key)
|
|
727
|
+
const tagName = Object.keys(vdom)[0];
|
|
728
|
+
if (!tagName) return;
|
|
729
|
+
|
|
730
|
+
const element = document.createElement(tagName);
|
|
731
|
+
const props = vdom[tagName] || {};
|
|
732
|
+
|
|
733
|
+
// Set attributes and properties
|
|
734
|
+
Object.keys(props).forEach(key => {
|
|
735
|
+
if (key === 'children') {
|
|
736
|
+
// Handle children
|
|
737
|
+
const children = props.children;
|
|
738
|
+
if (Array.isArray(children)) {
|
|
739
|
+
children.forEach(child => {
|
|
740
|
+
renderVirtualDOMToElement(child, element);
|
|
741
|
+
});
|
|
742
|
+
} else if (children) {
|
|
743
|
+
renderVirtualDOMToElement(children, element);
|
|
744
|
+
}
|
|
745
|
+
} else if (key === 'text') {
|
|
746
|
+
element.textContent = props[key];
|
|
747
|
+
} else if (key.startsWith('on')) {
|
|
748
|
+
// Skip event handlers for now - they'll be attached separately
|
|
749
|
+
} else {
|
|
750
|
+
// Set attribute
|
|
751
|
+
element.setAttribute(key, props[key]);
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
container.appendChild(element);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Performs direct DOM updates by finding elements with data-ref attributes
|
|
761
|
+
* and updating their content to match the current state. This approach ensures
|
|
762
|
+
* that UI elements stay synchronized with component state without full re-rendering.
|
|
763
|
+
*
|
|
764
|
+
* @param {HTMLElement} rootElement - The root component element to search within
|
|
765
|
+
* @param {Object} state - The current state object containing updated values
|
|
766
|
+
* @since 0.1.2
|
|
767
|
+
*/
|
|
768
|
+
function updateDOMElementsDirectly(rootElement, state) {
|
|
769
|
+
// Update elements with data-ref attributes that correspond to state values
|
|
770
|
+
const refElements = rootElement.querySelectorAll('[data-ref]');
|
|
771
|
+
refElements.forEach(element => {
|
|
772
|
+
const ref = element.getAttribute('data-ref');
|
|
773
|
+
if (ref && state.hasOwnProperty(ref)) {
|
|
774
|
+
// Update text content based on the reference
|
|
775
|
+
if (ref === 'count') {
|
|
776
|
+
element.textContent = `Count: ${state.count}`;
|
|
777
|
+
} else if (ref === 'step') {
|
|
778
|
+
element.textContent = `Step: ${state.step}`;
|
|
779
|
+
} else {
|
|
780
|
+
element.textContent = state[ref];
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
// Update input values that correspond to state
|
|
786
|
+
const inputs = rootElement.querySelectorAll('input');
|
|
787
|
+
inputs.forEach(input => {
|
|
788
|
+
if (input.type === 'number' && state.step !== undefined) {
|
|
789
|
+
input.value = state.step;
|
|
790
|
+
} else if (input.type === 'text' && state.newTodo !== undefined) {
|
|
791
|
+
// DON'T override input value if user is actively typing
|
|
792
|
+
// Only update if the input is not focused (user not typing)
|
|
793
|
+
if (document.activeElement !== input) {
|
|
794
|
+
input.value = state.newTodo;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Updates dynamic content sections such as lists, statistics, and interactive elements.
|
|
802
|
+
* This function handles complex UI updates that require more than simple text replacement,
|
|
803
|
+
* including filtering, sorting, and structural changes to the DOM.
|
|
804
|
+
*
|
|
805
|
+
* @param {HTMLElement} rootElement - The root component element containing dynamic content
|
|
806
|
+
* @param {Object} state - The current state object with updated data
|
|
807
|
+
* @since 0.1.2
|
|
808
|
+
*/
|
|
809
|
+
function updateDynamicContent(rootElement, state) {
|
|
810
|
+
// Update todo list if present
|
|
811
|
+
if (state.todos !== undefined) {
|
|
812
|
+
updateTodoList(rootElement, state);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Update todo stats if present
|
|
816
|
+
if (state.todos !== undefined) {
|
|
817
|
+
updateTodoStats(rootElement, state);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Update filter buttons if present
|
|
821
|
+
if (state.filter !== undefined) {
|
|
822
|
+
updateFilterButtons(rootElement, state);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Updates the todo list display by rebuilding the list items based on current state.
|
|
828
|
+
* Handles filtering (all/active/completed) and creates new DOM elements for each todo.
|
|
829
|
+
* After updating the DOM, re-attaches event handlers to ensure interactivity.
|
|
830
|
+
*
|
|
831
|
+
* @param {HTMLElement} rootElement - The root component element containing the todo list
|
|
832
|
+
* @param {Object} state - The current state object containing todos array and filter settings
|
|
833
|
+
* @since 0.1.2
|
|
834
|
+
*/
|
|
835
|
+
function updateTodoList(rootElement, state) {
|
|
836
|
+
const todoList = rootElement.querySelector('.todo-list');
|
|
837
|
+
if (!todoList) return;
|
|
838
|
+
|
|
839
|
+
// Filter todos based on current filter
|
|
840
|
+
const filteredTodos = state.todos.filter(todo => {
|
|
841
|
+
if (state.filter === 'active') return !todo.completed;
|
|
842
|
+
if (state.filter === 'completed') return todo.completed;
|
|
843
|
+
return true;
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
// Clear current list
|
|
847
|
+
todoList.innerHTML = '';
|
|
848
|
+
|
|
849
|
+
// Add filtered todos
|
|
850
|
+
filteredTodos.forEach(todo => {
|
|
851
|
+
const li = document.createElement('li');
|
|
852
|
+
li.className = `todo-item ${todo.completed ? 'completed' : ''}`;
|
|
853
|
+
|
|
854
|
+
li.innerHTML = `
|
|
855
|
+
<input type="checkbox" ${todo.completed ? 'checked' : ''} class="todo-checkbox" data-todo-id="${todo.id}">
|
|
856
|
+
<span class="todo-text">${todo.text}</span>
|
|
857
|
+
<button class="btn btn-danger btn-small" data-todo-id="${todo.id}" data-action="remove">×</button>
|
|
858
|
+
`;
|
|
859
|
+
|
|
860
|
+
todoList.appendChild(li);
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
// Re-attach function-based event handlers to newly created DOM elements
|
|
864
|
+
// This is necessary because manually created DOM elements don't automatically get
|
|
865
|
+
// the function-based handlers from the virtual DOM
|
|
866
|
+
reattachTodoEventHandlers(rootElement, state);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Update todo statistics display
|
|
871
|
+
*
|
|
872
|
+
* @param {HTMLElement} rootElement - The root component element
|
|
873
|
+
* @param {Object} state - The new state
|
|
874
|
+
*/
|
|
875
|
+
function updateTodoStats(rootElement, state) {
|
|
876
|
+
const statsElement = rootElement.querySelector('.todo-stats');
|
|
877
|
+
if (!statsElement || !state.todos) return;
|
|
878
|
+
|
|
879
|
+
const stats = {
|
|
880
|
+
total: state.todos.length,
|
|
881
|
+
completed: state.todos.filter(todo => todo.completed).length,
|
|
882
|
+
active: state.todos.filter(todo => !todo.completed).length
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
statsElement.innerHTML = `
|
|
886
|
+
<span class="stat-item">Total: ${stats.total}</span>
|
|
887
|
+
<span class="stat-item">Active: ${stats.active}</span>
|
|
888
|
+
<span class="stat-item">Completed: ${stats.completed}</span>
|
|
889
|
+
`;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Update filter button states
|
|
894
|
+
*
|
|
895
|
+
* @param {HTMLElement} rootElement - The root component element
|
|
896
|
+
* @param {Object} state - The new state
|
|
897
|
+
*/
|
|
898
|
+
function updateFilterButtons(rootElement, state) {
|
|
899
|
+
const filterButtons = rootElement.querySelectorAll('.filter-btn');
|
|
900
|
+
filterButtons.forEach(button => {
|
|
901
|
+
const buttonText = button.textContent.toLowerCase();
|
|
902
|
+
if (buttonText === state.filter || (buttonText === 'all' && state.filter === 'all')) {
|
|
903
|
+
button.classList.add('active');
|
|
904
|
+
} else {
|
|
905
|
+
button.classList.remove('active');
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Re-attaches event handlers to dynamically created todo items after DOM updates.
|
|
912
|
+
* This function is essential for maintaining interactivity when todo items are
|
|
913
|
+
* recreated during state changes. Handles both delete buttons and toggle checkboxes.
|
|
914
|
+
*
|
|
915
|
+
* @param {HTMLElement} rootElement - The root component element containing todo items
|
|
916
|
+
* @param {Object} state - The current state object for context
|
|
917
|
+
* @since 0.1.2
|
|
918
|
+
*/
|
|
919
|
+
function reattachTodoEventHandlers(rootElement) {
|
|
920
|
+
// Find the component instance to get access to the component's handlers
|
|
921
|
+
const componentInstance = rootElement.__coherentInstance;
|
|
922
|
+
if (!componentInstance || !componentInstance.component) {
|
|
923
|
+
console.warn('⚠️ No component instance found for re-attaching todo event handlers');
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Get the component's removeTodo and toggleTodo functions
|
|
928
|
+
// These should be available in the component's scope
|
|
929
|
+
const component = componentInstance.component;
|
|
930
|
+
|
|
931
|
+
// Re-attach delete button handlers
|
|
932
|
+
const deleteButtons = rootElement.querySelectorAll('button[data-action="remove"]');
|
|
933
|
+
deleteButtons.forEach(button => {
|
|
934
|
+
const todoId = parseInt(button.getAttribute('data-todo-id'));
|
|
935
|
+
if (todoId) {
|
|
936
|
+
// Remove any existing handler to prevent duplicates
|
|
937
|
+
const handlerKey = `__coherent_click_handler`;
|
|
938
|
+
if (button[handlerKey]) {
|
|
939
|
+
button.removeEventListener('click', button[handlerKey]);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Create new handler that calls the component's removeTodo function
|
|
943
|
+
const clickHandler = (event) => {
|
|
944
|
+
event.preventDefault();
|
|
945
|
+
|
|
946
|
+
// Get current state and setState from component
|
|
947
|
+
if (component.__stateContainer) {
|
|
948
|
+
const currentState = component.__stateContainer.getState();
|
|
949
|
+
const setState = component.__stateContainer.setState.bind(component.__stateContainer);
|
|
950
|
+
|
|
951
|
+
// Remove the todo
|
|
952
|
+
setState({
|
|
953
|
+
todos: currentState.todos.filter(todo => todo.id !== todoId)
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
// Trigger DOM update to reflect the state change
|
|
957
|
+
const updatedState = component.__stateContainer.getState();
|
|
958
|
+
updateDOMWithState(rootElement, updatedState);
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
// Attach the handler
|
|
963
|
+
button.addEventListener('click', clickHandler);
|
|
964
|
+
button[handlerKey] = clickHandler;
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
// Re-attach checkbox handlers
|
|
969
|
+
const checkboxes = rootElement.querySelectorAll('.todo-checkbox');
|
|
970
|
+
checkboxes.forEach(checkbox => {
|
|
971
|
+
const todoId = parseInt(checkbox.getAttribute('data-todo-id'));
|
|
972
|
+
if (todoId) {
|
|
973
|
+
// Remove any existing handler to prevent duplicates
|
|
974
|
+
const handlerKey = `__coherent_change_handler`;
|
|
975
|
+
if (checkbox[handlerKey]) {
|
|
976
|
+
checkbox.removeEventListener('change', checkbox[handlerKey]);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Create new handler that calls the component's toggleTodo function
|
|
980
|
+
const changeHandler = () => {
|
|
981
|
+
// Get current state and setState from component
|
|
982
|
+
if (component.__stateContainer) {
|
|
983
|
+
const currentState = component.__stateContainer.getState();
|
|
984
|
+
const setState = component.__stateContainer.setState.bind(component.__stateContainer);
|
|
985
|
+
|
|
986
|
+
// Toggle the todo
|
|
987
|
+
setState({
|
|
988
|
+
todos: currentState.todos.map(todo =>
|
|
989
|
+
todo.id === todoId ? { ...todo, completed: !todo.completed } : todo
|
|
990
|
+
)
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
// Trigger DOM update to reflect the state change
|
|
994
|
+
const updatedState = component.__stateContainer.getState();
|
|
995
|
+
updateDOMWithState(rootElement, updatedState);
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
// Attach the handler
|
|
1000
|
+
checkbox.addEventListener('change', changeHandler);
|
|
1001
|
+
checkbox[handlerKey] = changeHandler;
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Attaches function-based event listeners from virtual DOM definitions to real DOM elements.
|
|
1008
|
+
* This is the core mechanism that enables interactive components by bridging the gap
|
|
1009
|
+
* between virtual DOM event handlers and actual browser events. Prevents duplicate
|
|
1010
|
+
* handlers and ensures proper state management integration.
|
|
1011
|
+
*
|
|
1012
|
+
* @param {HTMLElement} rootElement - The root DOM element to search for event targets
|
|
1013
|
+
* @param {Object} virtualElement - The virtual DOM element containing function handlers
|
|
1014
|
+
* @param {Object} instance - The component instance providing state and context
|
|
1015
|
+
* @since 0.1.2
|
|
1016
|
+
*/
|
|
1017
|
+
function attachFunctionEventListeners(rootElement, virtualElement, instance, options = {}) {
|
|
1018
|
+
if (!rootElement || !virtualElement || typeof window === 'undefined') {
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Helper function to traverse virtual DOM and find function handlers
|
|
1023
|
+
function traverseAndAttach(domElement, vElement, path = []) {
|
|
1024
|
+
if (!vElement || typeof vElement !== 'object') return;
|
|
1025
|
+
|
|
1026
|
+
// Handle array of virtual elements
|
|
1027
|
+
if (Array.isArray(vElement)) {
|
|
1028
|
+
vElement.forEach((child, index) => {
|
|
1029
|
+
const childElement = domElement.children[index];
|
|
1030
|
+
if (childElement) {
|
|
1031
|
+
traverseAndAttach(childElement, child, [...path, index]);
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Handle single virtual element
|
|
1038
|
+
const tagName = Object.keys(vElement)[0];
|
|
1039
|
+
const elementProps = vElement[tagName];
|
|
1040
|
+
|
|
1041
|
+
if (elementProps && typeof elementProps === 'object') {
|
|
1042
|
+
// Look for event handler functions
|
|
1043
|
+
const eventHandlers = ['onclick', 'onchange', 'oninput', 'onfocus', 'onblur', 'onsubmit', 'onkeypress', 'onkeydown', 'onkeyup', 'onmouseenter', 'onmouseleave'];
|
|
1044
|
+
|
|
1045
|
+
eventHandlers.forEach(eventName => {
|
|
1046
|
+
const handler = elementProps[eventName];
|
|
1047
|
+
if (typeof handler === 'function') {
|
|
1048
|
+
const eventType = eventName.substring(2); // Remove 'on' prefix
|
|
1049
|
+
|
|
1050
|
+
// If inputsOnly option is set, only attach input-related events and click events on dynamically generated elements
|
|
1051
|
+
if (options.inputsOnly) {
|
|
1052
|
+
const inputEvents = ['input', 'change', 'keypress'];
|
|
1053
|
+
const isDynamicElement = domElement.closest('.todo-item') || domElement.closest('[data-dynamic]');
|
|
1054
|
+
|
|
1055
|
+
if (!inputEvents.includes(eventType) && !(eventType === 'click' && isDynamicElement)) {
|
|
1056
|
+
return; // Skip non-input events except clicks on dynamic elements
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Special handling for input events
|
|
1061
|
+
|
|
1062
|
+
// Check if handler is already attached to prevent duplicates
|
|
1063
|
+
const handlerKey = `__coherent_${eventType}_handler`;
|
|
1064
|
+
if (domElement[handlerKey]) {
|
|
1065
|
+
// Remove the old handler first
|
|
1066
|
+
domElement.removeEventListener(eventType, domElement[handlerKey]);
|
|
1067
|
+
delete domElement[handlerKey];
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Create a wrapper that provides component context
|
|
1071
|
+
const wrappedHandler = (event) => {
|
|
1072
|
+
try {
|
|
1073
|
+
// Only prevent default for non-input events and non-form events
|
|
1074
|
+
if (eventType !== 'input' && eventType !== 'change' && eventType !== 'keypress') {
|
|
1075
|
+
event.preventDefault();
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Execute the function handler with proper context
|
|
1079
|
+
|
|
1080
|
+
// Extract state and setState from the component's current execution context
|
|
1081
|
+
let currentState = {};
|
|
1082
|
+
let currentSetState = () => {};
|
|
1083
|
+
|
|
1084
|
+
// For withState components, use the state container
|
|
1085
|
+
if (instance.component && instance.component.__stateContainer) {
|
|
1086
|
+
currentState = instance.component.__stateContainer.getState();
|
|
1087
|
+
currentSetState = (newState) => {
|
|
1088
|
+
// Call the component's setState method
|
|
1089
|
+
instance.component.__stateContainer.setState(newState);
|
|
1090
|
+
|
|
1091
|
+
// Update the instance state for consistency
|
|
1092
|
+
if (instance.state && typeof newState === 'object') {
|
|
1093
|
+
Object.assign(instance.state, newState);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Get the updated state after setState
|
|
1097
|
+
instance.component.__stateContainer.getState();
|
|
1098
|
+
|
|
1099
|
+
// Trigger component re-render to reflect the new state
|
|
1100
|
+
const componentRoot = domElement.closest('[data-coherent-component]');
|
|
1101
|
+
if (componentRoot && componentRoot.__coherentInstance) {
|
|
1102
|
+
componentRoot.__coherentInstance.rerender();
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
} else if (instance.state) {
|
|
1106
|
+
// Fallback for non-withState components
|
|
1107
|
+
currentState = instance.state;
|
|
1108
|
+
currentSetState = (newState) => {
|
|
1109
|
+
if (typeof newState === 'object') {
|
|
1110
|
+
Object.assign(instance.state, newState);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// Trigger component re-render to reflect the new state
|
|
1114
|
+
const componentRoot = domElement.closest('[data-coherent-component]');
|
|
1115
|
+
if (componentRoot && componentRoot.__coherentInstance) {
|
|
1116
|
+
componentRoot.__coherentInstance.rerender();
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// Call the original handler with event, state, and setState
|
|
1122
|
+
const result = handler.call(domElement, event, currentState, currentSetState);
|
|
1123
|
+
|
|
1124
|
+
return result;
|
|
1125
|
+
} catch (_error) {
|
|
1126
|
+
console.error(`Error in ${eventName} handler:`, _error);
|
|
1127
|
+
}
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
// Remove any existing onclick attributes that might interfere
|
|
1131
|
+
if (domElement.hasAttribute(eventName)) {
|
|
1132
|
+
domElement.removeAttribute(eventName);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Store the handler reference to prevent duplicates
|
|
1136
|
+
domElement[handlerKey] = wrappedHandler;
|
|
1137
|
+
|
|
1138
|
+
// Add the new event listener (use capture only for non-input events)
|
|
1139
|
+
const useCapture = eventType !== 'input' && eventType !== 'change';
|
|
1140
|
+
domElement.addEventListener(eventType, wrappedHandler, useCapture);
|
|
1141
|
+
|
|
1142
|
+
// Input event handler attached successfully
|
|
1143
|
+
|
|
1144
|
+
// Add to instance's event listeners for cleanup
|
|
1145
|
+
if (instance.eventListeners && Array.isArray(instance.eventListeners)) {
|
|
1146
|
+
instance.eventListeners.push({
|
|
1147
|
+
element: domElement,
|
|
1148
|
+
event: eventType,
|
|
1149
|
+
handler: wrappedHandler
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
// Recursively handle children
|
|
1156
|
+
if (elementProps.children) {
|
|
1157
|
+
const children = Array.isArray(elementProps.children) ? elementProps.children : [elementProps.children];
|
|
1158
|
+
children.forEach((child, index) => {
|
|
1159
|
+
const childElement = domElement.children[index];
|
|
1160
|
+
if (childElement && child) {
|
|
1161
|
+
traverseAndAttach(childElement, child, [...path, 'children', index]);
|
|
1162
|
+
}
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// Start traversal from the root
|
|
1169
|
+
traverseAndAttach(rootElement, virtualElement);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* Attach event listeners from data attributes
|
|
1174
|
+
*
|
|
1175
|
+
* @param {HTMLElement} element - The root element
|
|
1176
|
+
* @param {Object} instance - The component instance
|
|
1177
|
+
*/
|
|
1178
|
+
function attachEventListeners(element, instance) {
|
|
1179
|
+
// Check if we're in a browser environment
|
|
1180
|
+
try {
|
|
1181
|
+
// Clear any existing event listeners if this is a re-hydration
|
|
1182
|
+
if (instance && instance.eventListeners && Array.isArray(instance.eventListeners)) {
|
|
1183
|
+
instance.eventListeners.forEach(({ element, event, handler }) => {
|
|
1184
|
+
if (element && typeof element.removeEventListener === 'function') {
|
|
1185
|
+
element.removeEventListener(event, handler);
|
|
1186
|
+
}
|
|
1187
|
+
});
|
|
1188
|
+
instance.eventListeners = [];
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Check if we're in a browser environment
|
|
1192
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Check if element has required methods
|
|
1197
|
+
if (!element || typeof element.querySelectorAll !== 'function') {
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// Find all elements with data-action attributes
|
|
1202
|
+
const actionElements = element.querySelectorAll('[data-action]');
|
|
1203
|
+
|
|
1204
|
+
actionElements.forEach(actionElement => {
|
|
1205
|
+
// Check if element has required methods
|
|
1206
|
+
if (!actionElement || typeof actionElement.getAttribute !== 'function') return;
|
|
1207
|
+
|
|
1208
|
+
const action = actionElement.getAttribute('data-action');
|
|
1209
|
+
const target = actionElement.getAttribute('data-target') || 'default';
|
|
1210
|
+
const event = actionElement.getAttribute('data-event') || 'click';
|
|
1211
|
+
|
|
1212
|
+
if (action) {
|
|
1213
|
+
const handler = (e) => {
|
|
1214
|
+
if (e && typeof e.preventDefault === 'function') {
|
|
1215
|
+
e.preventDefault(); // Prevent default behavior for better control
|
|
1216
|
+
}
|
|
1217
|
+
handleComponentAction(e, action, target, instance);
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1220
|
+
// Add event listener
|
|
1221
|
+
if (typeof actionElement.addEventListener === 'function') {
|
|
1222
|
+
actionElement.addEventListener(event, handler);
|
|
1223
|
+
|
|
1224
|
+
// Store for cleanup
|
|
1225
|
+
if (instance.eventListeners && Array.isArray(instance.eventListeners)) {
|
|
1226
|
+
instance.eventListeners.push({
|
|
1227
|
+
element: actionElement,
|
|
1228
|
+
event,
|
|
1229
|
+
handler
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
// Check for inline event attributes and warn users to use safer alternatives
|
|
1237
|
+
const eventAttributes = ['onclick', 'onchange', 'oninput', 'onfocus', 'onblur', 'onsubmit'];
|
|
1238
|
+
|
|
1239
|
+
eventAttributes.forEach(eventName => {
|
|
1240
|
+
const attributeSelector = `[${eventName}]`;
|
|
1241
|
+
const elements = element.querySelectorAll(attributeSelector);
|
|
1242
|
+
|
|
1243
|
+
elements.forEach(elementWithEvent => {
|
|
1244
|
+
// Check if element has required methods
|
|
1245
|
+
if (!elementWithEvent || typeof elementWithEvent.getAttribute !== 'function') return;
|
|
1246
|
+
|
|
1247
|
+
const eventAttr = elementWithEvent.getAttribute(eventName);
|
|
1248
|
+
|
|
1249
|
+
if (eventAttr) {
|
|
1250
|
+
// Warn about inline event attributes - they are not supported for security reasons
|
|
1251
|
+
console.warn(
|
|
1252
|
+
`[Coherent.js] Inline event attribute "${eventName}="${eventAttr}" found but not supported.\n` +
|
|
1253
|
+
`For security and CSP compliance, use one of these alternatives:\n` +
|
|
1254
|
+
`1. Function-based handlers: Pass functions directly in virtual DOM\n` +
|
|
1255
|
+
`2. Data-action registry: <button data-action="actionId" data-event="click">\n` +
|
|
1256
|
+
`3. Event registry: <button data-coherent-event="handlerId" data-coherent-event-type="click">\n` +
|
|
1257
|
+
`See documentation for details.`
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
// Also look for data-action attributes (new approach)
|
|
1264
|
+
const dataActionElements = element.querySelectorAll('[data-action]');
|
|
1265
|
+
|
|
1266
|
+
dataActionElements.forEach(actionElement => {
|
|
1267
|
+
// Check if element has required methods
|
|
1268
|
+
if (!actionElement || typeof actionElement.getAttribute !== 'function') return;
|
|
1269
|
+
|
|
1270
|
+
const actionId = actionElement.getAttribute('data-action');
|
|
1271
|
+
const eventType = actionElement.getAttribute('data-event') || 'click';
|
|
1272
|
+
|
|
1273
|
+
if (actionId) {
|
|
1274
|
+
// Get the function from the action registry
|
|
1275
|
+
let handlerFunc = null;
|
|
1276
|
+
|
|
1277
|
+
// Try to get from action registry (server-side stored)
|
|
1278
|
+
if (typeof window !== 'undefined' && window.__coherentActionRegistry && window.__coherentActionRegistry[actionId]) {
|
|
1279
|
+
handlerFunc = window.__coherentActionRegistry[actionId];
|
|
1280
|
+
} else {
|
|
1281
|
+
console.warn(`No handler found for action ${actionId}`, window.__coherentActionRegistry);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
if (handlerFunc && typeof handlerFunc === 'function') {
|
|
1285
|
+
// Mark as processed to avoid duplicate handling
|
|
1286
|
+
if (typeof actionElement.hasAttribute === 'function' && !actionElement.hasAttribute(`data-hydrated-${eventType}`)) {
|
|
1287
|
+
actionElement.setAttribute(`data-hydrated-${eventType}`, 'true');
|
|
1288
|
+
|
|
1289
|
+
const handler = (e) => {
|
|
1290
|
+
try {
|
|
1291
|
+
// Try to find the component instance associated with this element
|
|
1292
|
+
let componentElement = actionElement;
|
|
1293
|
+
while (componentElement && !componentElement.hasAttribute('data-coherent-component')) {
|
|
1294
|
+
componentElement = componentElement.parentElement;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
if (componentElement && componentElement.__coherentInstance) {
|
|
1298
|
+
// We found the component instance
|
|
1299
|
+
const instance = componentElement.__coherentInstance;
|
|
1300
|
+
const state = instance.state || {};
|
|
1301
|
+
const setState = instance.setState || (() => {});
|
|
1302
|
+
|
|
1303
|
+
// Call the handler function with the element as context and pass event, state, setState
|
|
1304
|
+
handlerFunc.call(actionElement, e, state, setState);
|
|
1305
|
+
} else {
|
|
1306
|
+
// Fallback: call the handler without component context
|
|
1307
|
+
handlerFunc.call(actionElement, e);
|
|
1308
|
+
}
|
|
1309
|
+
} catch (_error) {
|
|
1310
|
+
console.warn(`Error executing action handler for ${actionId}:`, _error);
|
|
1311
|
+
}
|
|
1312
|
+
};
|
|
1313
|
+
|
|
1314
|
+
if (typeof actionElement.addEventListener === 'function') {
|
|
1315
|
+
actionElement.addEventListener(eventType, handler);
|
|
1316
|
+
if (instance && instance.eventListeners && Array.isArray(instance.eventListeners)) {
|
|
1317
|
+
instance.eventListeners.push({
|
|
1318
|
+
element: actionElement,
|
|
1319
|
+
event: eventType,
|
|
1320
|
+
handler
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
// Also look for Coherent-specific event handlers (data-coherent-event)
|
|
1330
|
+
const coherentEventElements = element.querySelectorAll('[data-coherent-event]');
|
|
1331
|
+
|
|
1332
|
+
coherentEventElements.forEach(elementWithCoherentEvent => {
|
|
1333
|
+
// Check if element has required methods
|
|
1334
|
+
if (!elementWithCoherentEvent || typeof elementWithCoherentEvent.getAttribute !== 'function') return;
|
|
1335
|
+
|
|
1336
|
+
const eventId = elementWithCoherentEvent.getAttribute('data-coherent-event');
|
|
1337
|
+
const eventType = elementWithCoherentEvent.getAttribute('data-coherent-event-type');
|
|
1338
|
+
|
|
1339
|
+
if (eventId && eventType) {
|
|
1340
|
+
// Get the function from the registry
|
|
1341
|
+
let handlerFunc = null;
|
|
1342
|
+
|
|
1343
|
+
// Try to get from global registry (server-side stored)
|
|
1344
|
+
if (typeof window !== 'undefined' && window.__coherentEventRegistry && window.__coherentEventRegistry[eventId]) {
|
|
1345
|
+
handlerFunc = window.__coherentEventRegistry[eventId];
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
if (handlerFunc && typeof handlerFunc === 'function') {
|
|
1349
|
+
// Mark as processed to avoid duplicate handling
|
|
1350
|
+
if (typeof elementWithCoherentEvent.hasAttribute === 'function' && !elementWithCoherentEvent.hasAttribute(`data-hydrated-${eventType}`)) {
|
|
1351
|
+
elementWithCoherentEvent.setAttribute(`data-hydrated-${eventType}`, 'true');
|
|
1352
|
+
|
|
1353
|
+
const handler = (e) => {
|
|
1354
|
+
try {
|
|
1355
|
+
// Call the original function with proper context
|
|
1356
|
+
// Pass the event, state, and setState as parameters
|
|
1357
|
+
const state = instance.state || {};
|
|
1358
|
+
const setState = instance.setState || (() => {});
|
|
1359
|
+
|
|
1360
|
+
// Bind the function to the element and call it with the event
|
|
1361
|
+
handlerFunc.call(elementWithCoherentEvent, e, state, setState);
|
|
1362
|
+
} catch (_error) {
|
|
1363
|
+
console.warn(`Error executing coherent event handler:`, _error);
|
|
1364
|
+
}
|
|
1365
|
+
};
|
|
1366
|
+
|
|
1367
|
+
if (typeof elementWithCoherentEvent.addEventListener === 'function') {
|
|
1368
|
+
elementWithCoherentEvent.addEventListener(eventType, handler);
|
|
1369
|
+
if (instance.eventListeners && Array.isArray(instance.eventListeners)) {
|
|
1370
|
+
instance.eventListeners.push({
|
|
1371
|
+
element: elementWithCoherentEvent,
|
|
1372
|
+
event: eventType,
|
|
1373
|
+
handler
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
} catch (_error) {
|
|
1383
|
+
console.warn('Error attaching event listeners:', _error);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
/**
|
|
1388
|
+
* Handle component actions
|
|
1389
|
+
*
|
|
1390
|
+
* @param {Event} event - The DOM event
|
|
1391
|
+
* @param {string} action - The action name
|
|
1392
|
+
* @param {string} target - The target identifier
|
|
1393
|
+
* @param {Object} instance - The component instance
|
|
1394
|
+
*/
|
|
1395
|
+
function handleComponentAction(event, action, target, instance) {
|
|
1396
|
+
// Handle common actions
|
|
1397
|
+
switch (action) {
|
|
1398
|
+
case 'increment':
|
|
1399
|
+
if (instance.state && instance.state.count !== undefined) {
|
|
1400
|
+
const step = instance.state.step || 1;
|
|
1401
|
+
instance.setState({count: instance.state.count + step});
|
|
1402
|
+
|
|
1403
|
+
// Update the DOM directly for immediate feedback
|
|
1404
|
+
const countElement = instance.element.querySelector('[data-ref="count"]');
|
|
1405
|
+
if (countElement) {
|
|
1406
|
+
countElement.textContent = `Count: ${instance.state.count + step}`;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
break;
|
|
1410
|
+
case 'decrement':
|
|
1411
|
+
if (instance.state && instance.state.count !== undefined) {
|
|
1412
|
+
const step = instance.state.step || 1;
|
|
1413
|
+
instance.setState({count: instance.state.count - step});
|
|
1414
|
+
|
|
1415
|
+
// Update the DOM directly for immediate feedback
|
|
1416
|
+
const countElement = instance.element.querySelector('[data-ref="count"]');
|
|
1417
|
+
if (countElement) {
|
|
1418
|
+
countElement.textContent = `Count: ${instance.state.count - step}`;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
break;
|
|
1422
|
+
case 'reset':
|
|
1423
|
+
if (instance.state) {
|
|
1424
|
+
const initialCount = instance.props.initialCount || 0;
|
|
1425
|
+
instance.setState({count: initialCount});
|
|
1426
|
+
|
|
1427
|
+
// Update the DOM directly for immediate feedback
|
|
1428
|
+
const countElement = instance.element.querySelector('[data-ref="count"]');
|
|
1429
|
+
if (countElement) {
|
|
1430
|
+
countElement.textContent = `Count: ${initialCount}`;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
break;
|
|
1434
|
+
case 'changeStep':
|
|
1435
|
+
// Get the input value
|
|
1436
|
+
const inputElement = event.target;
|
|
1437
|
+
if (inputElement && inputElement.value) {
|
|
1438
|
+
const stepValue = parseInt(inputElement.value, 10);
|
|
1439
|
+
if (!isNaN(stepValue) && stepValue >= 1 && stepValue <= 10) {
|
|
1440
|
+
instance.setState({step: stepValue});
|
|
1441
|
+
|
|
1442
|
+
// Update the DOM directly for immediate feedback
|
|
1443
|
+
const stepElement = instance.element.querySelector('[data-ref="step"]');
|
|
1444
|
+
if (stepElement) {
|
|
1445
|
+
stepElement.textContent = `Step: ${stepValue}`;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
break;
|
|
1450
|
+
case 'toggle':
|
|
1451
|
+
const todoIndex = event.target && event.target.getAttribute ?
|
|
1452
|
+
parseInt(event.target.getAttribute('data-todo-index')) : -1;
|
|
1453
|
+
if (todoIndex >= 0 && instance.state && instance.state.todos && instance.state.todos[todoIndex]) {
|
|
1454
|
+
const newTodos = [...instance.state.todos];
|
|
1455
|
+
newTodos[todoIndex].completed = !newTodos[todoIndex].completed;
|
|
1456
|
+
instance.setState({todos: newTodos});
|
|
1457
|
+
}
|
|
1458
|
+
break;
|
|
1459
|
+
case 'add':
|
|
1460
|
+
if (typeof document !== 'undefined' && document.getElementById) {
|
|
1461
|
+
const input = document.getElementById(`new-todo-${target}`);
|
|
1462
|
+
if (input && input.value && input.value.trim()) {
|
|
1463
|
+
if (instance.state && instance.state.todos) {
|
|
1464
|
+
const newTodos = [
|
|
1465
|
+
...instance.state.todos,
|
|
1466
|
+
{ text: input.value.trim(), completed: false }
|
|
1467
|
+
];
|
|
1468
|
+
instance.setState({todos: newTodos});
|
|
1469
|
+
input.value = '';
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
break;
|
|
1474
|
+
default:
|
|
1475
|
+
// Check if this is a custom method on the instance
|
|
1476
|
+
if (instance && typeof instance[action] === 'function') {
|
|
1477
|
+
try {
|
|
1478
|
+
// Call the custom method on the instance
|
|
1479
|
+
instance[action](event, target);
|
|
1480
|
+
} catch (_error) {
|
|
1481
|
+
console.warn(`Error executing custom action ${action}:`, _error);
|
|
1482
|
+
}
|
|
1483
|
+
} else {
|
|
1484
|
+
// Check if this is a function handler in the action registry
|
|
1485
|
+
if (typeof window !== 'undefined' && window.__coherentActionRegistry && window.__coherentActionRegistry[action]) {
|
|
1486
|
+
const handlerFunc = window.__coherentActionRegistry[action];
|
|
1487
|
+
|
|
1488
|
+
// Get the component state and setState function if available
|
|
1489
|
+
const state = instance ? (instance.state || {}) : {};
|
|
1490
|
+
const setState = instance && instance.setState ? instance.setState.bind(instance) : (() => {});
|
|
1491
|
+
|
|
1492
|
+
try {
|
|
1493
|
+
// Call the handler function with event, state, and setState
|
|
1494
|
+
handlerFunc(event, state, setState);
|
|
1495
|
+
} catch (_error) {
|
|
1496
|
+
console.warn(`Error executing action handler ${action}:`, _error);
|
|
1497
|
+
}
|
|
1498
|
+
} else {
|
|
1499
|
+
// Custom action handling would go here
|
|
1500
|
+
// Custom action executed
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
/**
|
|
1507
|
+
* Hydrate multiple elements with their corresponding components
|
|
1508
|
+
*
|
|
1509
|
+
* @param {Array} elements - Array of DOM elements to hydrate
|
|
1510
|
+
* @param {Array} components - Array of Coherent component functions
|
|
1511
|
+
* @param {Array} propsArray - Array of props for each component
|
|
1512
|
+
* @returns {Array} Array of hydrated component instances
|
|
1513
|
+
*/
|
|
1514
|
+
function hydrateAll(elements, components, propsArray = []) {
|
|
1515
|
+
if (elements.length !== components.length) {
|
|
1516
|
+
throw new Error('Number of elements must match number of components');
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
return elements.map((element, index) => {
|
|
1520
|
+
const component = components[index];
|
|
1521
|
+
const props = propsArray[index] || {};
|
|
1522
|
+
return hydrate(element, component, props);
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* Find and hydrate elements by CSS selector
|
|
1528
|
+
*
|
|
1529
|
+
* @param {string} selector - CSS selector to find elements
|
|
1530
|
+
* @param {Function} component - The Coherent component function
|
|
1531
|
+
* @param {Object} props - The props to pass to the component
|
|
1532
|
+
* @returns {Array} Array of hydrated component instances
|
|
1533
|
+
*/
|
|
1534
|
+
function hydrateBySelector(selector, component, props = {}) {
|
|
1535
|
+
if (typeof window === 'undefined' || !document.querySelectorAll) {
|
|
1536
|
+
return [];
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
const elements = document.querySelectorAll(selector);
|
|
1540
|
+
return Array.from(elements).map(element => hydrate(element, component, props));
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/**
|
|
1544
|
+
* Enable client-side interactivity for event handlers
|
|
1545
|
+
*
|
|
1546
|
+
* @param {HTMLElement} rootElement - The root element to enable events on
|
|
1547
|
+
*/
|
|
1548
|
+
function enableClientEvents(rootElement = document) {
|
|
1549
|
+
if (typeof window === 'undefined' || !rootElement.querySelectorAll) {
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// This function is now handled automatically during hydration
|
|
1554
|
+
// but can be called to enable events on dynamically added elements
|
|
1555
|
+
// Client events enabled
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* Create a hydratable component
|
|
1560
|
+
*
|
|
1561
|
+
* @param {Function} component - The Coherent component function
|
|
1562
|
+
* @param {Object} options - Hydration options
|
|
1563
|
+
* @returns {Function} A component that can be hydrated
|
|
1564
|
+
*/
|
|
1565
|
+
function makeHydratable(component, options = {}) {
|
|
1566
|
+
// Extract component name from options or use function name
|
|
1567
|
+
const componentName = options.componentName || component.name || 'AnonymousComponent';
|
|
1568
|
+
|
|
1569
|
+
// Create a new function that wraps the original component
|
|
1570
|
+
const hydratableComponent = function(props = {}) {
|
|
1571
|
+
return component(props);
|
|
1572
|
+
};
|
|
1573
|
+
|
|
1574
|
+
// Set the component name on the hydratable component function
|
|
1575
|
+
Object.defineProperty(hydratableComponent, 'name', {
|
|
1576
|
+
value: componentName,
|
|
1577
|
+
writable: false
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
// Copy all properties from the original component, including withState metadata
|
|
1581
|
+
Object.keys(component).forEach(key => {
|
|
1582
|
+
hydratableComponent[key] = component[key];
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
// Copy prototype if it exists
|
|
1586
|
+
if (component.prototype) {
|
|
1587
|
+
hydratableComponent.prototype = Object.create(component.prototype);
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
// Special handling for withState wrapped components
|
|
1591
|
+
if (component.__wrappedComponent && component.__stateContainer) {
|
|
1592
|
+
hydratableComponent.__wrappedComponent = component.__wrappedComponent;
|
|
1593
|
+
hydratableComponent.__stateContainer = component.__stateContainer;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// Add hydration metadata to the component
|
|
1597
|
+
hydratableComponent.isHydratable = true;
|
|
1598
|
+
hydratableComponent.hydrationOptions = options;
|
|
1599
|
+
|
|
1600
|
+
// Add auto-hydration functionality
|
|
1601
|
+
hydratableComponent.autoHydrate = function(componentRegistry = {}) {
|
|
1602
|
+
// Register this component if not already registered
|
|
1603
|
+
if (!componentRegistry[hydratableComponent.name || 'AnonymousComponent']) {
|
|
1604
|
+
componentRegistry[hydratableComponent.name || 'AnonymousComponent'] = hydratableComponent;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// Call the global autoHydrate function
|
|
1608
|
+
autoHydrate(componentRegistry);
|
|
1609
|
+
};
|
|
1610
|
+
|
|
1611
|
+
// Mark this component as hydratable
|
|
1612
|
+
hydratableComponent.isHydratable = true;
|
|
1613
|
+
|
|
1614
|
+
// Add a method to manually set hydration data for cases where we need to override
|
|
1615
|
+
hydratableComponent.withHydrationData = function(customProps = {}, customState = null) {
|
|
1616
|
+
return {
|
|
1617
|
+
render: function(props = {}) {
|
|
1618
|
+
const mergedProps = { ...customProps, ...props };
|
|
1619
|
+
const result = hydratableComponent(mergedProps);
|
|
1620
|
+
const hydrationData = hydratableComponent.getHydrationData(mergedProps, customState);
|
|
1621
|
+
|
|
1622
|
+
// Add hydration attributes to the root element
|
|
1623
|
+
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
|
1624
|
+
const tagName = Object.keys(result)[0];
|
|
1625
|
+
const elementProps = result[tagName];
|
|
1626
|
+
|
|
1627
|
+
if (elementProps && typeof elementProps === 'object') {
|
|
1628
|
+
// Add hydration attributes
|
|
1629
|
+
Object.keys(hydrationData.hydrationAttributes).forEach(attr => {
|
|
1630
|
+
const value = hydrationData.hydrationAttributes[attr];
|
|
1631
|
+
if (value !== null) {
|
|
1632
|
+
elementProps[attr] = value;
|
|
1633
|
+
}
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
return result;
|
|
1639
|
+
}
|
|
1640
|
+
};
|
|
1641
|
+
};
|
|
1642
|
+
|
|
1643
|
+
// Add a method to get hydration data
|
|
1644
|
+
hydratableComponent.getHydrationData = function(props = {}, state = null) {
|
|
1645
|
+
return {
|
|
1646
|
+
componentName: componentName,
|
|
1647
|
+
props,
|
|
1648
|
+
initialState: options.initialState,
|
|
1649
|
+
// Add data attributes for hydration
|
|
1650
|
+
hydrationAttributes: {
|
|
1651
|
+
'data-coherent-component': componentName,
|
|
1652
|
+
'data-coherent-state': state ? JSON.stringify(state) : (options.initialState ? JSON.stringify(options.initialState) : null),
|
|
1653
|
+
'data-coherent-props': Object.keys(props).length > 0 ? JSON.stringify(props) : null
|
|
1654
|
+
}
|
|
1655
|
+
};
|
|
1656
|
+
};
|
|
1657
|
+
|
|
1658
|
+
// Add a method to render with hydration data
|
|
1659
|
+
hydratableComponent.renderWithHydration = function(props = {}) {
|
|
1660
|
+
const result = hydratableComponent(props);
|
|
1661
|
+
|
|
1662
|
+
// Try to extract state from the component if it's a withState wrapped component
|
|
1663
|
+
let state = null;
|
|
1664
|
+
if (hydratableComponent.__wrappedComponent && hydratableComponent.__stateContainer) {
|
|
1665
|
+
// This is a withState wrapped component, try to get its state
|
|
1666
|
+
try {
|
|
1667
|
+
state = hydratableComponent.__stateContainer.getState();
|
|
1668
|
+
} catch (e) {
|
|
1669
|
+
// If we can't get the state, that's OK
|
|
1670
|
+
console.warn('Could not get component state:', e);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
const hydrationData = hydratableComponent.getHydrationData(props, state);
|
|
1675
|
+
|
|
1676
|
+
// Add hydration attributes to the root element
|
|
1677
|
+
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
|
1678
|
+
const tagName = Object.keys(result)[0];
|
|
1679
|
+
const elementProps = result[tagName];
|
|
1680
|
+
|
|
1681
|
+
if (elementProps && typeof elementProps === 'object') {
|
|
1682
|
+
// Add hydration attributes
|
|
1683
|
+
Object.keys(hydrationData.hydrationAttributes).forEach(attr => {
|
|
1684
|
+
const value = hydrationData.hydrationAttributes[attr];
|
|
1685
|
+
if (value !== null) {
|
|
1686
|
+
elementProps[attr] = value;
|
|
1687
|
+
}
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
return result;
|
|
1693
|
+
};
|
|
1694
|
+
|
|
1695
|
+
return hydratableComponent;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* Auto-hydrate all components on page load
|
|
1700
|
+
*
|
|
1701
|
+
* @param {Object} componentRegistry - Registry of component functions
|
|
1702
|
+
*/
|
|
1703
|
+
function autoHydrate(componentRegistry = {}) {
|
|
1704
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// autoHydrate called
|
|
1709
|
+
|
|
1710
|
+
// Check if registry is actually the window object (common mistake)
|
|
1711
|
+
if (componentRegistry === window) {
|
|
1712
|
+
console.warn('⚠️ Component registry is the window object! This suggests the registry was not properly initialized.');
|
|
1713
|
+
// Falling back to window.componentRegistry
|
|
1714
|
+
componentRegistry = window.componentRegistry || {};
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// Initialize registries if they don't exist
|
|
1718
|
+
window.__coherentEventRegistry = window.__coherentEventRegistry || {};
|
|
1719
|
+
window.__coherentActionRegistry = window.__coherentActionRegistry || {};
|
|
1720
|
+
|
|
1721
|
+
// Wait for DOM to be ready
|
|
1722
|
+
const hydrateComponents = () => {
|
|
1723
|
+
const hydrateableElements = document.querySelectorAll('[data-coherent-component]');
|
|
1724
|
+
|
|
1725
|
+
hydrateableElements.forEach(element => {
|
|
1726
|
+
const componentName = element.getAttribute('data-coherent-component');
|
|
1727
|
+
|
|
1728
|
+
// Look for the component in the registry
|
|
1729
|
+
let component = componentRegistry[componentName];
|
|
1730
|
+
|
|
1731
|
+
// If not found by exact name, try to find it by checking if it's a hydratable component
|
|
1732
|
+
if (!component) {
|
|
1733
|
+
// Component not found by name, searching registry...
|
|
1734
|
+
for (const comp of Object.values(componentRegistry)) {
|
|
1735
|
+
if (comp && comp.isHydratable) {
|
|
1736
|
+
component = comp;
|
|
1737
|
+
break;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
if (!component) {
|
|
1743
|
+
console.error(`❌ Component ${componentName} not found in registry`);
|
|
1744
|
+
return; // Skip this element
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
if (component) {
|
|
1748
|
+
try {
|
|
1749
|
+
// Extract props from data attributes
|
|
1750
|
+
const propsAttr = element.getAttribute('data-coherent-props');
|
|
1751
|
+
const props = propsAttr ? JSON.parse(propsAttr) : {};
|
|
1752
|
+
|
|
1753
|
+
// Extract initial state
|
|
1754
|
+
const stateAttr = element.getAttribute('data-coherent-state');
|
|
1755
|
+
const initialState = stateAttr ? JSON.parse(stateAttr) : null;
|
|
1756
|
+
|
|
1757
|
+
// Hydrate the component
|
|
1758
|
+
const instance = hydrate(element, component, props, { initialState });
|
|
1759
|
+
|
|
1760
|
+
if (instance) {
|
|
1761
|
+
// Component auto-hydrated successfully
|
|
1762
|
+
} else {
|
|
1763
|
+
console.warn(`❌ Failed to hydrate component: ${componentName}`);
|
|
1764
|
+
}
|
|
1765
|
+
} catch (_error) {
|
|
1766
|
+
console.error(`❌ Failed to auto-hydrate component ${componentName}:`, _error);
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
// Also enable client events for any remaining elements
|
|
1772
|
+
enableClientEvents();
|
|
1773
|
+
};
|
|
1774
|
+
|
|
1775
|
+
// Run hydration when DOM is ready
|
|
1776
|
+
if (document.readyState === 'loading') {
|
|
1777
|
+
document.addEventListener('DOMContentLoaded', hydrateComponents);
|
|
1778
|
+
} else {
|
|
1779
|
+
hydrateComponents();
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// Also export individual functions for convenience
|
|
1784
|
+
export {
|
|
1785
|
+
hydrate,
|
|
1786
|
+
hydrateAll,
|
|
1787
|
+
hydrateBySelector,
|
|
1788
|
+
enableClientEvents,
|
|
1789
|
+
makeHydratable,
|
|
1790
|
+
autoHydrate
|
|
1791
|
+
};
|