@coherent.js/client 1.0.0-beta.7 → 1.0.0-rc.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/dist/index.js CHANGED
@@ -1,1017 +1,2208 @@
1
- // src/hydration.js
2
- var componentInstances = /* @__PURE__ */ new WeakMap();
3
- function getKey(vNode) {
4
- if (!vNode || typeof vNode !== "object" || Array.isArray(vNode)) {
5
- return void 0;
1
+ // src/events/registry.js
2
+ var HandlerRegistry = class {
3
+ constructor() {
4
+ this.handlers = /* @__PURE__ */ new Map();
6
5
  }
7
- const tagName = Object.keys(vNode)[0];
8
- const props = vNode[tagName];
9
- if (props && typeof props === "object") {
10
- return props.key;
6
+ /**
7
+ * Register a handler with optional component context
8
+ * @param {string} handlerId - Unique identifier for the handler
9
+ * @param {Function} handler - The event handler function
10
+ * @param {object|null} componentRef - Optional component reference with state/setState
11
+ */
12
+ register(handlerId, handler, componentRef = null) {
13
+ if (typeof handler !== "function") {
14
+ throw new Error(`Handler must be a function, received: ${typeof handler}`);
15
+ }
16
+ this.handlers.set(handlerId, { handler, componentRef });
17
+ }
18
+ /**
19
+ * Unregister a handler by ID
20
+ * @param {string} handlerId - The handler ID to remove
21
+ * @returns {boolean} True if handler was removed, false if not found
22
+ */
23
+ unregister(handlerId) {
24
+ return this.handlers.delete(handlerId);
25
+ }
26
+ /**
27
+ * Get a handler entry by ID
28
+ * @param {string} handlerId - The handler ID to look up
29
+ * @returns {{handler: Function, componentRef: object|null}|undefined} Handler entry or undefined
30
+ */
31
+ get(handlerId) {
32
+ return this.handlers.get(handlerId);
33
+ }
34
+ /**
35
+ * Check if a handler is registered
36
+ * @param {string} handlerId - The handler ID to check
37
+ * @returns {boolean} True if handler exists
38
+ */
39
+ has(handlerId) {
40
+ return this.handlers.has(handlerId);
41
+ }
42
+ /**
43
+ * Clear all registered handlers
44
+ */
45
+ clear() {
46
+ this.handlers.clear();
11
47
  }
12
- return void 0;
48
+ /**
49
+ * Get all handler IDs registered for a specific component
50
+ * @param {object} componentRef - The component reference to search for
51
+ * @returns {string[]} Array of handler IDs belonging to this component
52
+ */
53
+ getByComponent(componentRef) {
54
+ if (!componentRef) {
55
+ return [];
56
+ }
57
+ const handlerIds = [];
58
+ for (const [handlerId, entry] of this.handlers) {
59
+ if (entry.componentRef === componentRef) {
60
+ handlerIds.push(handlerId);
61
+ }
62
+ }
63
+ return handlerIds;
64
+ }
65
+ /**
66
+ * Get the number of registered handlers
67
+ * @returns {number} Count of registered handlers
68
+ */
69
+ get size() {
70
+ return this.handlers.size;
71
+ }
72
+ };
73
+ var handlerRegistry = new HandlerRegistry();
74
+
75
+ // src/events/wrapper.js
76
+ function wrapEvent(originalEvent, target, componentRef = null) {
77
+ return {
78
+ // Native event access
79
+ originalEvent,
80
+ target,
81
+ // Delegate common methods
82
+ preventDefault() {
83
+ originalEvent.preventDefault();
84
+ },
85
+ stopPropagation() {
86
+ originalEvent.stopPropagation();
87
+ },
88
+ // Component context (null if no componentRef provided)
89
+ component: componentRef?.component ?? null,
90
+ state: componentRef?.state ?? null,
91
+ setState: componentRef?.setState ?? null,
92
+ props: componentRef?.props ?? null
93
+ };
13
94
  }
14
- function extractInitialState(element, options = {}) {
15
- if (typeof window === "undefined") {
16
- return options.initialState || null;
95
+
96
+ // src/events/delegation.js
97
+ var EventDelegation = class {
98
+ /**
99
+ * @param {import('./registry.js').HandlerRegistry} [registry] - Handler registry instance
100
+ */
101
+ constructor(registry = handlerRegistry) {
102
+ this.registry = registry;
103
+ this.initialized = false;
104
+ this.root = null;
105
+ this.boundHandlers = /* @__PURE__ */ new Map();
106
+ this.eventTypes = [
107
+ "click",
108
+ "change",
109
+ "input",
110
+ "submit",
111
+ "focus",
112
+ "blur",
113
+ "keydown",
114
+ "keyup",
115
+ "keypress"
116
+ ];
17
117
  }
18
- if (!element || typeof element.getAttribute !== "function") {
19
- return options.initialState || null;
118
+ /**
119
+ * Initialize event delegation by attaching listeners to the root element
120
+ * @param {Document|Element} [root=document] - Root element for event delegation
121
+ */
122
+ initialize(root = typeof document !== "undefined" ? document : null) {
123
+ if (this.initialized) {
124
+ return;
125
+ }
126
+ if (!root) {
127
+ return;
128
+ }
129
+ this.root = root;
130
+ for (const eventType of this.eventTypes) {
131
+ const handler = (event) => this.handleEvent(event, eventType);
132
+ const useCapture = eventType === "focus" || eventType === "blur";
133
+ const options = {
134
+ capture: useCapture,
135
+ passive: eventType !== "submit"
136
+ };
137
+ root.addEventListener(eventType, handler, options);
138
+ this.boundHandlers.set(eventType, { handler, options });
139
+ }
140
+ this.initialized = true;
20
141
  }
142
+ /**
143
+ * Handle a delegated event
144
+ * @param {Event} event - The DOM event
145
+ * @param {string} eventType - The type of event (click, change, etc.)
146
+ */
147
+ handleEvent(event, eventType) {
148
+ const target = event.target;
149
+ if (!target || typeof target.closest !== "function") {
150
+ return;
151
+ }
152
+ const attrName = `data-coherent-${eventType}`;
153
+ const delegateTarget = target.closest(`[${attrName}]`);
154
+ if (!delegateTarget) {
155
+ return;
156
+ }
157
+ const handlerId = delegateTarget.getAttribute(attrName);
158
+ if (!handlerId) {
159
+ return;
160
+ }
161
+ const entry = this.registry.get(handlerId);
162
+ if (!entry) {
163
+ return;
164
+ }
165
+ const wrappedEvent = wrapEvent(event, delegateTarget, entry.componentRef);
166
+ entry.handler(wrappedEvent);
167
+ }
168
+ /**
169
+ * Destroy the event delegation system
170
+ * Removes all listeners and clears the registry
171
+ */
172
+ destroy() {
173
+ if (!this.initialized || !this.root) {
174
+ return;
175
+ }
176
+ for (const [eventType, { handler, options }] of this.boundHandlers) {
177
+ this.root.removeEventListener(eventType, handler, options);
178
+ }
179
+ this.boundHandlers.clear();
180
+ this.registry.clear();
181
+ this.initialized = false;
182
+ this.root = null;
183
+ }
184
+ /**
185
+ * Check if the delegation system is initialized
186
+ * @returns {boolean} True if initialized
187
+ */
188
+ isInitialized() {
189
+ return this.initialized;
190
+ }
191
+ };
192
+ var eventDelegation = new EventDelegation();
193
+
194
+ // src/hydration/state-serializer.js
195
+ function serializeState(state) {
196
+ if (!state || typeof state !== "object") return null;
197
+ const serializable = {};
198
+ let hasSerializable = false;
199
+ for (const [key, value] of Object.entries(state)) {
200
+ if (isSerializable(value)) {
201
+ serializable[key] = value;
202
+ hasSerializable = true;
203
+ }
204
+ }
205
+ if (!hasSerializable) return null;
21
206
  try {
22
- const stateAttr = element.getAttribute("data-coherent-state");
23
- if (stateAttr) {
24
- return JSON.parse(stateAttr);
25
- }
26
- const state = {};
27
- let hasState = false;
28
- const countAttr = element.getAttribute("data-count");
29
- if (countAttr !== null) {
30
- state.count = parseInt(countAttr, 10) || 0;
31
- hasState = true;
32
- }
33
- const stepAttr = element.getAttribute("data-step");
34
- if (stepAttr !== null) {
35
- state.step = parseInt(stepAttr, 10) || 1;
36
- hasState = true;
37
- }
38
- const todosAttr = element.getAttribute("data-todos");
39
- if (todosAttr) {
40
- state.todos = JSON.parse(todosAttr);
41
- hasState = true;
42
- }
43
- const valueAttr = element.getAttribute("data-value");
44
- if (valueAttr !== null) {
45
- state.value = valueAttr;
46
- hasState = true;
47
- }
48
- if (options.initialState) {
49
- return { ...options.initialState, ...state };
50
- }
51
- return hasState ? state : null;
52
- } catch (_error) {
53
- console.warn("Error extracting initial state:", _error);
54
- return options.initialState || null;
207
+ const json = JSON.stringify(serializable);
208
+ return btoa(encodeURIComponent(json));
209
+ } catch (e) {
210
+ console.warn("[Coherent.js] Failed to serialize state:", e);
211
+ return null;
55
212
  }
56
213
  }
57
- function hydrate(element, component, props = {}, options = {}) {
58
- if (typeof window === "undefined") {
59
- console.warn("Hydration can only be performed in a browser environment");
214
+ function deserializeState(encoded) {
215
+ if (!encoded || typeof encoded !== "string") return null;
216
+ try {
217
+ const json = decodeURIComponent(atob(encoded));
218
+ return JSON.parse(json);
219
+ } catch (e) {
220
+ console.warn("[Coherent.js] Failed to deserialize state:", e);
60
221
  return null;
61
222
  }
62
- if (typeof component !== "function") {
63
- console.error("Hydrate error: component must be a function, received:", typeof component);
223
+ }
224
+ function extractState(element) {
225
+ if (!element || typeof element.getAttribute !== "function") {
64
226
  return null;
65
227
  }
66
- if (componentInstances.has(element)) {
67
- const existingInstance = componentInstances.get(element);
68
- return existingInstance;
69
- }
70
- const initialState = extractInitialState(element, options);
71
- const instance = {
72
- element,
73
- component,
74
- props: { ...props },
75
- state: initialState,
76
- isHydrated: true,
77
- eventListeners: [],
78
- options: { ...options },
79
- previousVirtualElement: null,
80
- // Update method for re-rendering
81
- update(newProps) {
82
- this.props = { ...this.props, ...newProps };
83
- this.rerender();
84
- return this;
85
- },
86
- // Re-render the component with current state
87
- rerender() {
88
- try {
89
- this.fallbackRerender();
90
- } catch (_error) {
91
- console.error("Error during component re-render:", _error);
92
- }
93
- },
94
- // Fallback re-render method using existing patching
95
- fallbackRerender() {
96
- try {
97
- const componentProps2 = { ...this.props, ...this.state || {} };
98
- if (typeof this.component !== "function") {
99
- console.error("Component is not a function:", this.component);
100
- return;
101
- }
102
- const newVirtualElement = this.component(componentProps2);
103
- if (!this.previousVirtualElement) {
104
- this.previousVirtualElement = this.virtualElementFromDOM(this.element);
105
- }
106
- this.patchDOM(this.element, this.previousVirtualElement, newVirtualElement);
107
- attachFunctionEventListeners(this.element, newVirtualElement, this, { inputsOnly: true });
108
- this.previousVirtualElement = newVirtualElement;
109
- } catch (_error) {
110
- console.error("Error during component re-render (fallback):", _error);
111
- }
112
- },
113
- // Create virtual element representation from existing DOM
114
- virtualElementFromDOM(domElement) {
115
- if (typeof window === "undefined" || typeof Node === "undefined") {
116
- return null;
117
- }
118
- if (domElement.nodeType === Node.TEXT_NODE) {
119
- return domElement.textContent;
120
- }
121
- if (domElement.nodeType !== Node.ELEMENT_NODE) {
122
- return null;
123
- }
124
- const tagName = domElement.tagName.toLowerCase();
125
- const props2 = {};
126
- const children = [];
127
- if (domElement.attributes) {
128
- Array.from(domElement.attributes).forEach((attr) => {
129
- const name = attr.name === "class" ? "className" : attr.name;
130
- props2[name] = attr.value;
131
- });
132
- }
133
- if (domElement.childNodes) {
134
- Array.from(domElement.childNodes).forEach((child) => {
135
- if (child.nodeType === Node.TEXT_NODE) {
136
- const text = child.textContent.trim();
137
- if (text) children.push(text);
138
- } else if (child.nodeType === Node.ELEMENT_NODE) {
139
- const childVNode = this.virtualElementFromDOM(child);
140
- if (childVNode) children.push(childVNode);
141
- }
142
- });
143
- }
144
- if (children.length > 0) {
145
- props2.children = children;
146
- }
147
- return { [tagName]: props2 };
148
- },
149
- // Intelligent DOM patching with minimal changes
150
- patchDOM(domElement, oldVNode, newVNode) {
151
- if (typeof newVNode === "string" || typeof newVNode === "number") {
152
- const newText = String(newVNode);
153
- if (typeof window === "undefined" || typeof Node === "undefined" || typeof document === "undefined") {
154
- return;
155
- }
156
- if (domElement.nodeType === Node.TEXT_NODE) {
157
- if (domElement.textContent !== newText) {
158
- domElement.textContent = newText;
159
- }
160
- } else {
161
- const textNode = document.createTextNode(newText);
162
- if (domElement.parentNode) {
163
- domElement.parentNode.replaceChild(textNode, domElement);
164
- }
165
- }
166
- return;
167
- }
168
- if (!newVNode) {
169
- domElement.remove();
170
- return;
171
- }
172
- if (Array.isArray(newVNode)) {
173
- console.warn("Array virtual node at root level");
174
- return;
175
- }
176
- const newTagName = Object.keys(newVNode)[0];
177
- if (domElement.tagName.toLowerCase() !== newTagName.toLowerCase()) {
178
- if (typeof window === "undefined" || typeof document === "undefined") {
179
- return;
180
- }
181
- const newElement = this.createDOMElement(newVNode);
182
- if (domElement.parentNode) {
183
- domElement.parentNode.replaceChild(newElement, domElement);
184
- }
185
- attachEventListeners(newElement, this);
186
- return;
187
- }
188
- this.patchAttributes(domElement, oldVNode, newVNode);
189
- this.patchChildren(domElement, oldVNode, newVNode);
190
- attachEventListeners(domElement, this);
191
- },
192
- // Patch element attributes efficiently
193
- patchAttributes(domElement, oldVNode, newVNode) {
194
- if (typeof window === "undefined" || typeof document === "undefined") {
195
- return;
196
- }
197
- if (!domElement || typeof domElement.setAttribute !== "function" || typeof domElement.removeAttribute !== "function") {
198
- return;
199
- }
200
- const oldTagName = oldVNode ? Object.keys(oldVNode)[0] : null;
201
- const newTagName = Object.keys(newVNode)[0];
202
- const oldProps = oldVNode && oldTagName ? oldVNode[oldTagName] || {} : {};
203
- const newProps = newVNode[newTagName] || {};
204
- Object.keys(oldProps).forEach((key) => {
205
- if (key === "children" || key === "text") return;
206
- if (!(key in newProps)) {
207
- const attrName = key === "className" ? "class" : key;
208
- domElement.removeAttribute(attrName);
209
- }
210
- });
211
- Object.keys(newProps).forEach((key) => {
212
- if (key === "children" || key === "text") return;
213
- const newValue = newProps[key];
214
- const oldValue = oldProps[key];
215
- if (newValue !== oldValue) {
216
- const attrName = key === "className" ? "class" : key;
217
- if (newValue === true) {
218
- domElement.setAttribute(attrName, "");
219
- } else if (newValue === false || newValue === null) {
220
- domElement.removeAttribute(attrName);
221
- } else {
222
- domElement.setAttribute(attrName, String(newValue));
223
- }
224
- }
225
- });
226
- },
227
- // Get children array from a vNode
228
- getVNodeChildren(vNode) {
229
- if (!vNode || typeof vNode !== "object" || Array.isArray(vNode)) {
230
- return [];
231
- }
232
- const tagName = Object.keys(vNode)[0];
233
- const props2 = vNode[tagName];
234
- if (!props2 || typeof props2 !== "object") {
235
- return [];
236
- }
237
- if (props2.children) {
238
- return Array.isArray(props2.children) ? props2.children : [props2.children];
239
- }
240
- if (props2.text !== void 0) {
241
- return [props2.text];
242
- }
243
- return [];
244
- },
245
- // Patch children with key-based reconciliation
246
- patchChildren(domElement, oldVNode, newVNode) {
247
- if (typeof window === "undefined" || typeof Node === "undefined" || typeof document === "undefined") {
248
- return;
249
- }
250
- if (!domElement || typeof domElement.childNodes === "undefined" || typeof domElement.appendChild !== "function") {
251
- return;
252
- }
253
- const oldChildren = this.getVNodeChildren(oldVNode);
254
- const newChildren = this.getVNodeChildren(newVNode);
255
- let domChildren = [];
256
- if (typeof Array.from === "function" && domElement.childNodes) {
257
- try {
258
- domChildren = Array.from(domElement.childNodes).filter((node) => {
259
- return node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE && node.textContent && node.textContent.trim();
260
- });
261
- } catch (_error) {
262
- console.warn("Failed to convert childNodes to array:", _error);
263
- domChildren = [];
264
- }
265
- }
266
- const oldKeyMap = /* @__PURE__ */ new Map();
267
- const oldIndexMap = /* @__PURE__ */ new Map();
268
- oldChildren.forEach((child, i) => {
269
- const key = getKey(child);
270
- if (key !== void 0) {
271
- oldKeyMap.set(key, { vNode: child, index: i, domNode: domChildren[i] });
272
- } else {
273
- oldIndexMap.set(i, { vNode: child, index: i, domNode: domChildren[i] });
274
- }
275
- });
276
- const usedOldNodes = /* @__PURE__ */ new Set();
277
- newChildren.forEach((newChild, newIndex) => {
278
- const newKey = getKey(newChild);
279
- let oldEntry = null;
280
- if (newKey !== void 0 && oldKeyMap.has(newKey)) {
281
- oldEntry = oldKeyMap.get(newKey);
282
- usedOldNodes.add(newKey);
283
- } else if (newKey === void 0 && oldIndexMap.has(newIndex)) {
284
- oldEntry = oldIndexMap.get(newIndex);
285
- usedOldNodes.add(`index:${newIndex}`);
286
- }
287
- if (oldEntry && oldEntry.domNode) {
288
- this.patchDOM(oldEntry.domNode, oldEntry.vNode, newChild);
289
- const currentPosition = Array.from(domElement.childNodes).filter((node) => {
290
- return node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE && node.textContent && node.textContent.trim();
291
- }).indexOf(oldEntry.domNode);
292
- if (currentPosition !== newIndex) {
293
- const referenceNode = domElement.childNodes[newIndex];
294
- if (referenceNode) {
295
- domElement.insertBefore(oldEntry.domNode, referenceNode);
296
- } else {
297
- domElement.appendChild(oldEntry.domNode);
298
- }
299
- }
300
- } else {
301
- const newElement = this.createDOMElement(newChild);
302
- if (newElement) {
303
- const referenceNode = domElement.childNodes[newIndex];
304
- if (referenceNode) {
305
- domElement.insertBefore(newElement, referenceNode);
306
- } else {
307
- domElement.appendChild(newElement);
308
- }
309
- }
310
- }
311
- });
312
- oldKeyMap.forEach((entry, key) => {
313
- if (!usedOldNodes.has(key) && entry.domNode && entry.domNode.parentNode) {
314
- entry.domNode.remove();
315
- }
316
- });
317
- oldIndexMap.forEach((entry, index) => {
318
- if (!usedOldNodes.has(`index:${index}`) && entry.domNode && entry.domNode.parentNode) {
319
- entry.domNode.remove();
320
- }
228
+ const encoded = element.getAttribute("data-state");
229
+ return deserializeState(encoded);
230
+ }
231
+ function isSerializable(value) {
232
+ if (value === void 0) return false;
233
+ if (value === null) return true;
234
+ if (typeof value === "function") return false;
235
+ if (typeof value === "symbol") return false;
236
+ if (typeof value === "bigint") return false;
237
+ if (Array.isArray(value)) {
238
+ return value.every(isSerializable);
239
+ }
240
+ if (typeof value === "object") {
241
+ return true;
242
+ }
243
+ return true;
244
+ }
245
+ var STATE_SIZE_WARNING_THRESHOLD = 10 * 1024;
246
+ function serializeStateWithWarning(state, componentName = "Unknown") {
247
+ const encoded = serializeState(state);
248
+ if (encoded && encoded.length > STATE_SIZE_WARNING_THRESHOLD) {
249
+ console.warn(
250
+ `[Coherent.js] Large state detected for component "${componentName}": ${Math.round(encoded.length / 1024)}KB. Consider using a state management solution for large datasets.`
251
+ );
252
+ }
253
+ return encoded;
254
+ }
255
+
256
+ // src/hydration/mismatch-detector.js
257
+ function formatPath(segments) {
258
+ if (!segments || segments.length === 0) return "root";
259
+ return segments.join(".");
260
+ }
261
+ function getVNodeChildren(vNode) {
262
+ if (!vNode || typeof vNode !== "object" || Array.isArray(vNode)) {
263
+ return [];
264
+ }
265
+ const tagName = Object.keys(vNode)[0];
266
+ const props = vNode[tagName];
267
+ if (!props || typeof props !== "object") {
268
+ return [];
269
+ }
270
+ if (props.children) {
271
+ return Array.isArray(props.children) ? props.children : [props.children];
272
+ }
273
+ if (props.text !== void 0) {
274
+ return [String(props.text)];
275
+ }
276
+ return [];
277
+ }
278
+ function detectMismatch(domElement, virtualNode, path = []) {
279
+ const mismatches = [];
280
+ if (virtualNode === null || virtualNode === void 0) {
281
+ return mismatches;
282
+ }
283
+ if (typeof virtualNode === "string" || typeof virtualNode === "number") {
284
+ const expectedText = String(virtualNode).trim();
285
+ let actualText;
286
+ if (domElement.nodeType === 3) {
287
+ actualText = domElement.textContent?.trim() || "";
288
+ } else {
289
+ actualText = domElement.textContent?.trim() || "";
290
+ }
291
+ if (actualText !== expectedText) {
292
+ mismatches.push({
293
+ path: formatPath(path),
294
+ type: "text",
295
+ expected: expectedText,
296
+ actual: actualText,
297
+ domPath: getDOMPath(domElement)
321
298
  });
322
- },
323
- // Create DOM element from virtual element
324
- createDOMElement(vNode) {
325
- if (typeof vNode === "string" || typeof vNode === "number") {
326
- return document.createTextNode(String(vNode));
327
- }
328
- if (!vNode || typeof vNode !== "object") {
329
- return document.createTextNode("");
330
- }
331
- if (Array.isArray(vNode)) {
332
- const fragment = document.createDocumentFragment();
333
- vNode.forEach((child) => {
334
- fragment.appendChild(this.createDOMElement(child));
299
+ }
300
+ return mismatches;
301
+ }
302
+ if (Array.isArray(virtualNode)) {
303
+ virtualNode.forEach((child, index) => {
304
+ const domChild = getDOMChildAtIndex(domElement, index);
305
+ if (domChild) {
306
+ const childMismatches = detectMismatch(
307
+ domChild,
308
+ child,
309
+ [...path, `[${index}]`]
310
+ );
311
+ mismatches.push(...childMismatches);
312
+ } else {
313
+ mismatches.push({
314
+ path: formatPath([...path, `[${index}]`]),
315
+ type: "missing_element",
316
+ expected: describeVNode(child),
317
+ actual: null,
318
+ domPath: `${getDOMPath(domElement)} > child[${index}]`
335
319
  });
336
- return fragment;
337
320
  }
338
- const tagName = Object.keys(vNode)[0];
339
- const props2 = vNode[tagName] || {};
340
- const element2 = document.createElement(tagName);
341
- Object.keys(props2).forEach((key) => {
342
- if (key === "children" || key === "text") return;
343
- const value = props2[key];
344
- const attrName = key === "className" ? "class" : key;
345
- if (value === true) {
346
- element2.setAttribute(attrName, "");
347
- } else if (value !== false && value !== null) {
348
- element2.setAttribute(attrName, String(value));
349
- }
350
- });
351
- if (props2.children) {
352
- const children = Array.isArray(props2.children) ? props2.children : [props2.children];
353
- children.forEach((child) => {
354
- element2.appendChild(this.createDOMElement(child));
321
+ });
322
+ return mismatches;
323
+ }
324
+ if (typeof virtualNode !== "object") {
325
+ return mismatches;
326
+ }
327
+ const tagName = Object.keys(virtualNode)[0];
328
+ const props = virtualNode[tagName] || {};
329
+ const domTagName = domElement.tagName?.toLowerCase();
330
+ if (domTagName !== tagName.toLowerCase()) {
331
+ mismatches.push({
332
+ path: formatPath(path),
333
+ type: "tagName",
334
+ expected: tagName,
335
+ actual: domTagName,
336
+ domPath: getDOMPath(domElement)
337
+ });
338
+ return mismatches;
339
+ }
340
+ const attributeChecks = [
341
+ { virtual: "className", dom: "class" },
342
+ { virtual: "id", dom: "id" },
343
+ { virtual: "type", dom: "type" },
344
+ { virtual: "value", dom: "value" },
345
+ { virtual: "checked", dom: "checked" },
346
+ { virtual: "disabled", dom: "disabled" },
347
+ { virtual: "href", dom: "href" },
348
+ { virtual: "src", dom: "src" }
349
+ ];
350
+ attributeChecks.forEach(({ virtual, dom }) => {
351
+ const expectedValue = props[virtual];
352
+ if (expectedValue === void 0) return;
353
+ const actualValue = domElement.getAttribute(dom);
354
+ const expectedStr = String(expectedValue);
355
+ if (typeof expectedValue === "boolean") {
356
+ const actualBool = actualValue !== null;
357
+ if (expectedValue !== actualBool) {
358
+ mismatches.push({
359
+ path: formatPath([...path, `@${dom}`]),
360
+ type: "attribute",
361
+ expected: expectedValue,
362
+ actual: actualBool,
363
+ domPath: getDOMPath(domElement)
355
364
  });
356
- } else if (props2.text) {
357
- element2.appendChild(document.createTextNode(String(props2.text)));
358
- }
359
- return element2;
360
- },
361
- // Render virtual element to HTML string
362
- renderVirtualElement(element2) {
363
- if (typeof element2 === "string" || typeof element2 === "number") {
364
- return String(element2);
365
- }
366
- if (!element2 || typeof element2 !== "object") {
367
- return "";
368
365
  }
369
- if (Array.isArray(element2)) {
370
- return element2.map((el) => this.renderVirtualElement(el)).join("");
371
- }
372
- const tagName = Object.keys(element2)[0];
373
- const props2 = element2[tagName];
374
- if (!props2 || typeof props2 !== "object") {
375
- return `<${tagName}></${tagName}>`;
376
- }
377
- let attributes = "";
378
- const children = [];
379
- Object.keys(props2).forEach((key) => {
380
- if (key === "children") {
381
- if (Array.isArray(props2.children)) {
382
- children.push(...props2.children);
383
- } else {
384
- children.push(props2.children);
385
- }
386
- } else if (key === "text") {
387
- children.push(props2.text);
388
- } else {
389
- const attrName = key === "className" ? "class" : key;
390
- const value = props2[key];
391
- if (value === true) {
392
- attributes += ` ${attrName}`;
393
- } else if (value !== false && value !== null && value !== void 0) {
394
- attributes += ` ${attrName}="${String(value).replace(/"/g, "&quot;")}"`;
395
- }
396
- }
366
+ return;
367
+ }
368
+ if (expectedStr !== actualValue) {
369
+ mismatches.push({
370
+ path: formatPath([...path, `@${dom}`]),
371
+ type: "attribute",
372
+ expected: expectedStr,
373
+ actual: actualValue,
374
+ domPath: getDOMPath(domElement)
397
375
  });
398
- const childrenHTML = children.map((child) => this.renderVirtualElement(child)).join("");
399
- const voidElements = /* @__PURE__ */ new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
400
- if (voidElements.has(tagName.toLowerCase())) {
401
- return `<${tagName}${attributes}>`;
402
- }
403
- return `<${tagName}${attributes}>${childrenHTML}</${tagName}>`;
404
- },
405
- // Destroy the component and clean up
406
- destroy() {
407
- this.eventListeners.forEach(({ element: element2, event, handler }) => {
408
- if (element2.removeEventListener) {
409
- element2.removeEventListener(event, handler);
410
- }
376
+ }
377
+ });
378
+ const vChildren = getVNodeChildren({ [tagName]: props });
379
+ const dChildren = getSignificantDOMChildren(domElement);
380
+ if (vChildren.length !== dChildren.length) {
381
+ mismatches.push({
382
+ path: formatPath([...path, "children"]),
383
+ type: "children_count",
384
+ expected: vChildren.length,
385
+ actual: dChildren.length,
386
+ domPath: getDOMPath(domElement)
387
+ });
388
+ }
389
+ const maxChildren = Math.max(vChildren.length, dChildren.length);
390
+ for (let i = 0; i < maxChildren; i++) {
391
+ const vChild = vChildren[i];
392
+ const dChild = dChildren[i];
393
+ if (vChild && dChild) {
394
+ const childMismatches = detectMismatch(
395
+ dChild,
396
+ vChild,
397
+ [...path, `children[${i}]`]
398
+ );
399
+ mismatches.push(...childMismatches);
400
+ } else if (vChild && !dChild) {
401
+ mismatches.push({
402
+ path: formatPath([...path, `children[${i}]`]),
403
+ type: "missing_dom_child",
404
+ expected: describeVNode(vChild),
405
+ actual: null,
406
+ domPath: getDOMPath(domElement)
407
+ });
408
+ } else if (!vChild && dChild) {
409
+ mismatches.push({
410
+ path: formatPath([...path, `children[${i}]`]),
411
+ type: "extra_dom_child",
412
+ expected: null,
413
+ actual: describeNode(dChild),
414
+ domPath: getDOMPath(domElement)
411
415
  });
412
- this.state = null;
413
- this.isHydrated = false;
414
- componentInstances.delete(this.element);
415
- },
416
- // Set state (for components with state)
417
- setState(newState) {
418
- if (!this.state) {
419
- this.state = {};
420
- }
421
- const oldState = { ...this.state };
422
- this.state = typeof newState === "function" ? { ...this.state, ...newState(this.state) } : { ...this.state, ...newState };
423
- this.rerender();
424
- if (this.onStateChange) {
425
- this.onStateChange(this.state, oldState);
426
- }
427
- },
428
- // Add event listener that will be cleaned up on destroy
429
- addEventListener(targetElement, event, handler) {
430
- if (targetElement.addEventListener) {
431
- targetElement.addEventListener(event, handler);
432
- this.eventListeners.push({ element: targetElement, event, handler });
433
- }
434
416
  }
435
- };
436
- componentInstances.set(element, instance);
437
- if (element && typeof element.setAttribute === "function") {
438
- element.__coherentInstance = instance;
439
- if (!element.hasAttribute("data-coherent-component")) {
440
- element.setAttribute("data-coherent-component", "true");
441
- }
442
- }
443
- const componentProps = { ...instance.props };
444
- if (instance.component.__stateContainer) {
445
- if (instance.state) {
446
- instance.component.__stateContainer.setState(instance.state);
447
- }
448
- instance.setState = (newState) => {
449
- instance.component.__stateContainer.setState(newState);
450
- const updatedState = instance.component.__stateContainer.getState();
451
- instance.state = updatedState;
452
- instance.rerender();
453
- };
454
417
  }
455
- const freshVirtualElement = instance.component(componentProps);
456
- if (freshVirtualElement && typeof freshVirtualElement === "object") {
457
- const tagName = Object.keys(freshVirtualElement)[0];
458
- if (freshVirtualElement[tagName]) {
418
+ return mismatches;
419
+ }
420
+ function reportMismatches(mismatches, options = {}) {
421
+ if (!mismatches || mismatches.length === 0) return;
422
+ const { componentName = "Unknown", strict = false } = options;
423
+ const header = `[Coherent.js] Hydration mismatch detected in "${componentName}"!
424
+ Found ${mismatches.length} difference(s) between server and client:
425
+ `;
426
+ const details = mismatches.map((m, i) => {
427
+ return `
428
+ ${i + 1}. ${m.type} at ${m.path}
429
+ DOM path: ${m.domPath}
430
+ Expected: ${JSON.stringify(m.expected)}
431
+ Actual: ${JSON.stringify(m.actual)}`;
432
+ }).join("");
433
+ const advice = "\n\nThis usually happens when:\n - Server renders with different data than client\n - Using Date.now(), Math.random(), or browser-only APIs during render\n - Component is not pure (has side effects during render)\n";
434
+ console.warn(header + details + advice);
435
+ if (strict) {
436
+ throw new Error(`Hydration failed: ${mismatches.length} mismatch(es) found. See console for details.`);
437
+ }
438
+ }
439
+ function getSignificantDOMChildren(element) {
440
+ if (!element || !element.childNodes) return [];
441
+ return Array.from(element.childNodes).filter((node) => {
442
+ if (node.nodeType === 1) return true;
443
+ if (node.nodeType === 3) {
444
+ return node.textContent && node.textContent.trim().length > 0;
445
+ }
446
+ return false;
447
+ });
448
+ }
449
+ function getDOMChildAtIndex(parent, index) {
450
+ const children = getSignificantDOMChildren(parent);
451
+ return children[index] || null;
452
+ }
453
+ function getDOMPath(element) {
454
+ if (!element || !element.tagName) return "(unknown)";
455
+ const parts = [];
456
+ let current = element;
457
+ while (current && current.tagName) {
458
+ let selector = current.tagName.toLowerCase();
459
+ if (current.id) {
460
+ selector += `#${current.id}`;
461
+ } else if (current.className && typeof current.className === "string") {
462
+ const classes = current.className.trim().split(/\s+/).slice(0, 2);
463
+ if (classes.length > 0 && classes[0]) {
464
+ selector += `.${classes.join(".")}`;
465
+ }
466
+ }
467
+ parts.unshift(selector);
468
+ current = current.parentElement;
469
+ if (parts.length > 5) {
470
+ parts.unshift("...");
471
+ break;
459
472
  }
460
473
  }
461
- attachFunctionEventListeners(element, freshVirtualElement, instance);
462
- return instance;
474
+ return parts.join(" > ");
463
475
  }
464
- var eventRegistry = {};
465
- function registerEventHandler(id, handler) {
466
- eventRegistry[id] = handler;
476
+ function describeVNode(vNode) {
477
+ if (typeof vNode === "string" || typeof vNode === "number") {
478
+ return `text: "${String(vNode).substring(0, 50)}"`;
479
+ }
480
+ if (Array.isArray(vNode)) {
481
+ return `array[${vNode.length}]`;
482
+ }
483
+ if (typeof vNode === "object" && vNode !== null) {
484
+ const tagName = Object.keys(vNode)[0];
485
+ return `<${tagName}>`;
486
+ }
487
+ return String(vNode);
467
488
  }
468
- if (typeof window !== "undefined") {
469
- window.__coherentEventRegistry = window.__coherentEventRegistry || {};
470
- window.__coherentActionRegistry = window.__coherentActionRegistry || {};
471
- window.__coherentEventHandler = function(eventId, element, event) {
472
- let handlerFunc = window.__coherentEventRegistry[eventId];
473
- if (!handlerFunc && window.__coherentActionRegistry[eventId]) {
474
- handlerFunc = window.__coherentActionRegistry[eventId];
475
- }
476
- if (handlerFunc) {
477
- let componentElement = element;
478
- while (componentElement && !componentElement.hasAttribute("data-coherent-component")) {
479
- componentElement = componentElement.parentElement;
489
+ function describeNode(node) {
490
+ if (!node) return "(null)";
491
+ if (node.nodeType === 3) {
492
+ return `text: "${(node.textContent || "").substring(0, 50)}"`;
493
+ }
494
+ if (node.nodeType === 1) {
495
+ return `<${node.tagName.toLowerCase()}>`;
496
+ }
497
+ return `node(type=${node.nodeType})`;
498
+ }
499
+
500
+ // src/hydrate.js
501
+ function hydrate(component, container, options = {}) {
502
+ if (typeof component !== "function") {
503
+ throw new Error(
504
+ `hydrate() requires a component function, received: ${typeof component}`
505
+ );
506
+ }
507
+ if (!container || typeof container.getAttribute !== "function") {
508
+ throw new Error(
509
+ `hydrate() requires a valid DOM element as container, received: ${container === null ? "null" : typeof container}`
510
+ );
511
+ }
512
+ eventDelegation.initialize();
513
+ const {
514
+ initialState: providedState,
515
+ detectMismatch: shouldDetectMismatch = true,
516
+ strict = false,
517
+ onMismatch,
518
+ props: additionalProps = {}
519
+ } = options;
520
+ let state = providedState ?? extractState(container) ?? {};
521
+ const eventListeners = [];
522
+ const registeredHandlerIds = /* @__PURE__ */ new Set();
523
+ const componentRef = {
524
+ getState: () => state,
525
+ setState: (newState) => {
526
+ if (typeof newState === "function") {
527
+ state = { ...state, ...newState(state) };
528
+ } else {
529
+ state = { ...state, ...newState };
480
530
  }
481
- if (componentElement && componentElement.__coherentInstance) {
482
- const instance = componentElement.__coherentInstance;
483
- const state = instance.state || {};
484
- const setState = instance.setState ? instance.setState.bind(instance) : (() => {
485
- });
486
- try {
487
- handlerFunc.call(element, event, state, setState);
488
- } catch (_error) {
489
- console.warn(`Error executing coherent event handler:`, _error);
490
- }
531
+ doRerender();
532
+ }
533
+ };
534
+ const componentProps = { ...additionalProps, ...state };
535
+ let virtualDOM = component(componentProps);
536
+ if (shouldDetectMismatch) {
537
+ const mismatches = detectMismatch(container, virtualDOM);
538
+ if (mismatches.length > 0) {
539
+ if (onMismatch) {
540
+ onMismatch(mismatches);
491
541
  } else {
492
- try {
493
- handlerFunc.call(element, event);
494
- } catch (_error) {
495
- console.warn(`Error executing coherent event handler (no component context):`, _error);
496
- }
542
+ reportMismatches(mismatches, {
543
+ componentName: component.name || "Anonymous",
544
+ strict
545
+ });
497
546
  }
498
- } else {
499
- console.warn(`Event handler not found for ID: ${eventId}`);
500
547
  }
548
+ }
549
+ registerEventHandlers(container, virtualDOM, componentRef, registeredHandlerIds);
550
+ function doRerender() {
551
+ const newProps = { ...additionalProps, ...state };
552
+ virtualDOM = component(newProps);
553
+ patchDOM(container, virtualDOM);
554
+ registerEventHandlers(container, virtualDOM, componentRef, registeredHandlerIds);
555
+ }
556
+ function unmount() {
557
+ for (const handlerId of registeredHandlerIds) {
558
+ handlerRegistry.unregister(handlerId);
559
+ }
560
+ registeredHandlerIds.clear();
561
+ for (const { element, event, handler, options: options2 } of eventListeners) {
562
+ element.removeEventListener(event, handler, options2);
563
+ }
564
+ eventListeners.length = 0;
565
+ container.removeAttribute("data-coherent-hydrated");
566
+ }
567
+ function rerender(newProps) {
568
+ if (newProps) {
569
+ Object.assign(additionalProps, newProps);
570
+ }
571
+ doRerender();
572
+ }
573
+ function getState() {
574
+ return { ...state };
575
+ }
576
+ function setState(newState) {
577
+ componentRef.setState(newState);
578
+ }
579
+ container.setAttribute("data-coherent-hydrated", "true");
580
+ return {
581
+ unmount,
582
+ rerender,
583
+ getState,
584
+ setState
501
585
  };
502
586
  }
503
- function attachFunctionEventListeners(rootElement, virtualElement, instance, options = {}) {
504
- if (!rootElement || !virtualElement || typeof window === "undefined") {
587
+ function registerEventHandlers(domElement, vNode, componentRef, handlerIds) {
588
+ if (!vNode || typeof vNode !== "object" || Array.isArray(vNode)) {
505
589
  return;
506
590
  }
507
- function traverseAndAttach(domElement, vElement, path = []) {
508
- if (!vElement || typeof vElement !== "object") return;
509
- if (Array.isArray(vElement)) {
510
- vElement.forEach((child, index) => {
511
- const childElement = domElement.children[index];
512
- if (childElement) {
513
- traverseAndAttach(childElement, child, [...path, index]);
514
- }
515
- });
516
- return;
591
+ const tagName = Object.keys(vNode)[0];
592
+ const props = vNode[tagName];
593
+ if (!props || typeof props !== "object") {
594
+ return;
595
+ }
596
+ const eventProps = Object.keys(props).filter(
597
+ (key) => key.startsWith("on") && typeof props[key] === "function"
598
+ );
599
+ for (const eventProp of eventProps) {
600
+ const eventType = eventProp.slice(2).toLowerCase();
601
+ const handler = props[eventProp];
602
+ const handlerId = `${tagName}-${eventType}-${Math.random().toString(36).slice(2, 9)}`;
603
+ handlerRegistry.register(handlerId, handler, componentRef);
604
+ handlerIds.add(handlerId);
605
+ const attrName = `data-coherent-${eventType}`;
606
+ if (domElement.setAttribute) {
607
+ domElement.setAttribute(attrName, handlerId);
517
608
  }
518
- const tagName = Object.keys(vElement)[0];
519
- const elementProps = vElement[tagName];
520
- if (elementProps && typeof elementProps === "object") {
521
- const eventHandlers = ["onclick", "onchange", "oninput", "onfocus", "onblur", "onsubmit", "onkeypress", "onkeydown", "onkeyup", "onmouseenter", "onmouseleave"];
522
- eventHandlers.forEach((eventName) => {
523
- const handler = elementProps[eventName];
524
- if (typeof handler === "function") {
525
- const eventType = eventName.substring(2);
526
- if (options.inputsOnly) {
527
- const inputEvents = ["input", "change", "keypress"];
528
- const isDynamicElement = domElement.closest(".todo-item") || domElement.closest("[data-dynamic]");
529
- if (!inputEvents.includes(eventType) && !(eventType === "click" && isDynamicElement)) {
530
- return;
531
- }
532
- }
533
- const handlerKey = `__coherent_${eventType}_handler`;
534
- if (domElement[handlerKey]) {
535
- domElement.removeEventListener(eventType, domElement[handlerKey]);
536
- delete domElement[handlerKey];
537
- }
538
- const wrappedHandler = (event) => {
539
- try {
540
- if (eventType !== "input" && eventType !== "change" && eventType !== "keypress") {
541
- event.preventDefault();
542
- }
543
- let currentState = {};
544
- let currentSetState = () => {
545
- };
546
- if (instance.component && instance.component.__stateContainer) {
547
- currentState = instance.component.__stateContainer.getState();
548
- currentSetState = (newState) => {
549
- instance.component.__stateContainer.setState(newState);
550
- if (instance.state && typeof newState === "object") {
551
- Object.assign(instance.state, newState);
552
- }
553
- instance.component.__stateContainer.getState();
554
- const componentRoot = domElement.closest("[data-coherent-component]");
555
- if (componentRoot && componentRoot.__coherentInstance) {
556
- componentRoot.__coherentInstance.rerender();
557
- }
558
- };
559
- } else if (instance.state) {
560
- currentState = instance.state;
561
- currentSetState = (newState) => {
562
- if (typeof newState === "object") {
563
- Object.assign(instance.state, newState);
564
- }
565
- const componentRoot = domElement.closest("[data-coherent-component]");
566
- if (componentRoot && componentRoot.__coherentInstance) {
567
- componentRoot.__coherentInstance.rerender();
568
- }
569
- };
570
- }
571
- const result = handler.call(domElement, event, currentState, currentSetState);
572
- return result;
573
- } catch (_error) {
574
- console.error(`Error in ${eventName} handler:`, _error);
575
- }
576
- };
577
- if (domElement.hasAttribute(eventName)) {
578
- domElement.removeAttribute(eventName);
579
- }
580
- domElement[handlerKey] = wrappedHandler;
581
- const useCapture = eventType !== "input" && eventType !== "change";
582
- domElement.addEventListener(eventType, wrappedHandler, useCapture);
583
- if (instance.eventListeners && Array.isArray(instance.eventListeners)) {
584
- instance.eventListeners.push({
585
- element: domElement,
586
- event: eventType,
587
- handler: wrappedHandler
588
- });
589
- }
590
- }
591
- });
592
- if (elementProps.children) {
593
- const children = Array.isArray(elementProps.children) ? elementProps.children : [elementProps.children];
594
- children.forEach((child, index) => {
595
- const childElement = domElement.children[index];
596
- if (childElement && child) {
597
- traverseAndAttach(childElement, child, [...path, "children", index]);
598
- }
609
+ }
610
+ const children = getVNodeChildren2(props);
611
+ const domChildren = getSignificantDOMChildren2(domElement);
612
+ children.forEach((child, index) => {
613
+ if (child && typeof child === "object" && !Array.isArray(child) && domChildren[index]) {
614
+ registerEventHandlers(domChildren[index], child, componentRef, handlerIds);
615
+ }
616
+ });
617
+ }
618
+ function patchDOM(domElement, vNode) {
619
+ if (!vNode || !domElement) {
620
+ return;
621
+ }
622
+ if (typeof vNode === "string" || typeof vNode === "number") {
623
+ if (domElement.textContent !== String(vNode)) {
624
+ domElement.textContent = String(vNode);
625
+ }
626
+ return;
627
+ }
628
+ if (Array.isArray(vNode)) {
629
+ return;
630
+ }
631
+ if (typeof vNode !== "object") {
632
+ return;
633
+ }
634
+ const tagName = Object.keys(vNode)[0];
635
+ const props = vNode[tagName] || {};
636
+ const attributeMap = {
637
+ className: "class",
638
+ htmlFor: "for"
639
+ };
640
+ for (const [key, value] of Object.entries(props)) {
641
+ if (key === "children" || key === "text" || key.startsWith("on")) {
642
+ continue;
643
+ }
644
+ const attrName = attributeMap[key] || key;
645
+ if (value === true) {
646
+ domElement.setAttribute(attrName, "");
647
+ } else if (value === false || value === null || value === void 0) {
648
+ domElement.removeAttribute(attrName);
649
+ } else if (domElement.getAttribute(attrName) !== String(value)) {
650
+ domElement.setAttribute(attrName, String(value));
651
+ }
652
+ }
653
+ if (props.text !== void 0) {
654
+ const textContent = String(props.text);
655
+ if (domElement.textContent !== textContent) {
656
+ domElement.textContent = textContent;
657
+ }
658
+ return;
659
+ }
660
+ const children = getVNodeChildren2(props);
661
+ const domChildren = getSignificantDOMChildren2(domElement);
662
+ children.forEach((child, index) => {
663
+ if (domChildren[index]) {
664
+ patchDOM(domChildren[index], child);
665
+ }
666
+ });
667
+ }
668
+ function getVNodeChildren2(props) {
669
+ if (!props) return [];
670
+ if (props.children) {
671
+ return Array.isArray(props.children) ? props.children : [props.children];
672
+ }
673
+ return [];
674
+ }
675
+ function getSignificantDOMChildren2(element) {
676
+ if (!element || !element.childNodes) return [];
677
+ return Array.from(element.childNodes).filter((node) => {
678
+ if (node.nodeType === 1) return true;
679
+ if (node.nodeType === 3) {
680
+ return node.textContent && node.textContent.trim().length > 0;
681
+ }
682
+ return false;
683
+ });
684
+ }
685
+
686
+ // src/hmr/cleanup-tracker.js
687
+ var CleanupTracker = class {
688
+ constructor() {
689
+ this.moduleResources = /* @__PURE__ */ new Map();
690
+ }
691
+ /**
692
+ * Create a tracked context for a module
693
+ *
694
+ * Returns an object with tracked versions of setTimeout, setInterval,
695
+ * addEventListener, and fetch that automatically clean up on module disposal.
696
+ *
697
+ * @param {string} moduleId - Unique identifier for the module
698
+ * @returns {Object} Tracked context with setTimeout, setInterval, etc.
699
+ */
700
+ createContext(moduleId) {
701
+ const resources = {
702
+ timers: /* @__PURE__ */ new Set(),
703
+ intervals: /* @__PURE__ */ new Set(),
704
+ listeners: [],
705
+ abortControllers: /* @__PURE__ */ new Set()
706
+ };
707
+ this.moduleResources.set(moduleId, resources);
708
+ const context = {
709
+ /**
710
+ * Tracked setTimeout - auto-removes from tracking on completion
711
+ * @param {Function} callback - Function to execute
712
+ * @param {number} delay - Delay in milliseconds
713
+ * @param {...*} args - Additional arguments to pass to callback
714
+ * @returns {number} Timer ID
715
+ */
716
+ setTimeout: (callback, delay, ...args) => {
717
+ const id = setTimeout(
718
+ (...a) => {
719
+ resources.timers.delete(id);
720
+ callback(...a);
721
+ },
722
+ delay,
723
+ ...args
724
+ );
725
+ resources.timers.add(id);
726
+ return id;
727
+ },
728
+ /**
729
+ * Tracked setInterval - stores in intervals set until cleared
730
+ * @param {Function} callback - Function to execute
731
+ * @param {number} delay - Interval in milliseconds
732
+ * @param {...*} args - Additional arguments to pass to callback
733
+ * @returns {number} Interval ID
734
+ */
735
+ setInterval: (callback, delay, ...args) => {
736
+ const id = setInterval(callback, delay, ...args);
737
+ resources.intervals.add(id);
738
+ return id;
739
+ },
740
+ /**
741
+ * Clear a tracked timeout
742
+ * @param {number} id - Timer ID to clear
743
+ */
744
+ clearTimeout: (id) => {
745
+ resources.timers.delete(id);
746
+ clearTimeout(id);
747
+ },
748
+ /**
749
+ * Clear a tracked interval
750
+ * @param {number} id - Interval ID to clear
751
+ */
752
+ clearInterval: (id) => {
753
+ resources.intervals.delete(id);
754
+ clearInterval(id);
755
+ },
756
+ /**
757
+ * Tracked addEventListener - stores listener info for removal on cleanup
758
+ * @param {EventTarget} target - Element or object to attach listener to
759
+ * @param {string} event - Event type
760
+ * @param {Function} handler - Event handler function
761
+ * @param {Object|boolean} [options] - Listener options
762
+ */
763
+ addEventListener: (target, event, handler, options) => {
764
+ target.addEventListener(event, handler, options);
765
+ resources.listeners.push({ target, event, handler, options });
766
+ },
767
+ /**
768
+ * Create a tracked AbortController
769
+ * @returns {AbortController} Tracked AbortController
770
+ */
771
+ createAbortController: () => {
772
+ const controller = new AbortController();
773
+ resources.abortControllers.add(controller);
774
+ return controller;
775
+ },
776
+ /**
777
+ * Tracked fetch - creates AbortController automatically, cleans up on completion
778
+ * @param {string|URL} url - URL to fetch
779
+ * @param {Object} [options] - Fetch options
780
+ * @returns {Promise<Response>} Fetch promise
781
+ */
782
+ fetch: (url, options = {}) => {
783
+ const controller = new AbortController();
784
+ resources.abortControllers.add(controller);
785
+ const mergedOptions = {
786
+ ...options,
787
+ signal: controller.signal
788
+ };
789
+ return fetch(url, mergedOptions).finally(() => {
790
+ resources.abortControllers.delete(controller);
599
791
  });
600
792
  }
793
+ };
794
+ return context;
795
+ }
796
+ /**
797
+ * Cleanup all resources for a module
798
+ *
799
+ * Called during HMR module disposal. Clears all timers, intervals,
800
+ * removes all event listeners, and aborts all pending fetch requests.
801
+ *
802
+ * @param {string} moduleId - Module identifier to clean up
803
+ */
804
+ cleanup(moduleId) {
805
+ const resources = this.moduleResources.get(moduleId);
806
+ if (!resources) return;
807
+ for (const id of resources.timers) {
808
+ clearTimeout(id);
809
+ }
810
+ resources.timers.clear();
811
+ for (const id of resources.intervals) {
812
+ clearInterval(id);
813
+ }
814
+ resources.intervals.clear();
815
+ for (const { target, event, handler, options } of resources.listeners) {
816
+ try {
817
+ target.removeEventListener(event, handler, options);
818
+ } catch {
819
+ }
601
820
  }
821
+ resources.listeners.length = 0;
822
+ for (const controller of resources.abortControllers) {
823
+ try {
824
+ controller.abort();
825
+ } catch {
826
+ }
827
+ }
828
+ resources.abortControllers.clear();
829
+ this.moduleResources.delete(moduleId);
602
830
  }
603
- traverseAndAttach(rootElement, virtualElement);
604
- }
605
- function attachEventListeners(element, instance) {
606
- try {
607
- if (instance && instance.eventListeners && Array.isArray(instance.eventListeners)) {
608
- instance.eventListeners.forEach(({ element: element2, event, handler }) => {
609
- if (element2 && typeof element2.removeEventListener === "function") {
610
- element2.removeEventListener(event, handler);
611
- }
612
- });
613
- instance.eventListeners = [];
831
+ /**
832
+ * Check for potential resource leaks (for development mode)
833
+ *
834
+ * Logs warnings if resources weren't cleaned up before module disposal.
835
+ * Call this before cleanup() to detect potential leaks.
836
+ *
837
+ * @param {string} moduleId - Module identifier to check
838
+ */
839
+ checkForLeaks(moduleId) {
840
+ const resources = this.moduleResources.get(moduleId);
841
+ if (!resources) return;
842
+ const warnings = [];
843
+ if (resources.timers.size > 0) {
844
+ warnings.push(`${resources.timers.size} timer(s) not cleaned up`);
614
845
  }
615
- if (typeof window === "undefined" || typeof document === "undefined") {
616
- return;
846
+ if (resources.intervals.size > 0) {
847
+ warnings.push(`${resources.intervals.size} interval(s) not cleaned up`);
617
848
  }
618
- if (!element || typeof element.querySelectorAll !== "function") {
619
- return;
849
+ if (resources.listeners.length > 0) {
850
+ warnings.push(`${resources.listeners.length} listener(s) not cleaned up`);
620
851
  }
621
- const actionElements = element.querySelectorAll("[data-action]");
622
- actionElements.forEach((actionElement) => {
623
- if (!actionElement || typeof actionElement.getAttribute !== "function") return;
624
- const action = actionElement.getAttribute("data-action");
625
- const target = actionElement.getAttribute("data-target") || "default";
626
- const event = actionElement.getAttribute("data-event") || "click";
627
- if (action) {
628
- const handler = (e) => {
629
- if (e && typeof e.preventDefault === "function") {
630
- e.preventDefault();
631
- }
632
- handleComponentAction(e, action, target, instance);
633
- };
634
- if (typeof actionElement.addEventListener === "function") {
635
- actionElement.addEventListener(event, handler);
636
- if (instance.eventListeners && Array.isArray(instance.eventListeners)) {
637
- instance.eventListeners.push({
638
- element: actionElement,
639
- event,
640
- handler
641
- });
642
- }
852
+ if (resources.abortControllers.size > 0) {
853
+ warnings.push(
854
+ `${resources.abortControllers.size} pending fetch(es) not aborted`
855
+ );
856
+ }
857
+ if (warnings.length > 0) {
858
+ console.warn(`[HMR] Potential leak in module ${moduleId}: ${warnings.join(", ")}`);
859
+ }
860
+ }
861
+ /**
862
+ * Check if a module has tracked resources
863
+ * @param {string} moduleId - Module identifier
864
+ * @returns {boolean} True if module has resources
865
+ */
866
+ hasResources(moduleId) {
867
+ return this.moduleResources.has(moduleId);
868
+ }
869
+ /**
870
+ * Get resource counts for a module (for testing/debugging)
871
+ * @param {string} moduleId - Module identifier
872
+ * @returns {Object|null} Resource counts or null if module not tracked
873
+ */
874
+ getResourceCounts(moduleId) {
875
+ const resources = this.moduleResources.get(moduleId);
876
+ if (!resources) return null;
877
+ return {
878
+ timers: resources.timers.size,
879
+ intervals: resources.intervals.size,
880
+ listeners: resources.listeners.length,
881
+ abortControllers: resources.abortControllers.size
882
+ };
883
+ }
884
+ };
885
+ var cleanupTracker = new CleanupTracker();
886
+
887
+ // src/hmr/state-capturer.js
888
+ var StateCapturer = class {
889
+ constructor() {
890
+ this.capturedInputs = /* @__PURE__ */ new Map();
891
+ this.scrollPositions = /* @__PURE__ */ new Map();
892
+ this.layoutSnapshot = null;
893
+ }
894
+ /**
895
+ * Generate a stable key for an input element
896
+ *
897
+ * Uses multiple factors to identify inputs across HMR updates:
898
+ * 1. ID (most stable)
899
+ * 2. Name + type
900
+ * 3. Form context
901
+ * 4. DOM path (fallback)
902
+ *
903
+ * @param {HTMLInputElement|HTMLTextAreaElement|HTMLSelectElement} input - Input element
904
+ * @returns {string} Stable key for the input
905
+ */
906
+ getInputKey(input) {
907
+ const parts = [];
908
+ if (input.id) {
909
+ parts.push(`#${input.id}`);
910
+ return parts.join(":");
911
+ }
912
+ if (input.name) {
913
+ parts.push(`[name="${input.name}"]`);
914
+ }
915
+ if (input.type) {
916
+ parts.push(`[type="${input.type}"]`);
917
+ }
918
+ if (input.form?.id) {
919
+ parts.push(`form#${input.form.id}`);
920
+ }
921
+ if (parts.length === 0) {
922
+ parts.push(this.getElementPath(input));
923
+ }
924
+ return parts.join(":");
925
+ }
926
+ /**
927
+ * Build a CSS-like path for an element
928
+ *
929
+ * @param {HTMLElement} element - Element to build path for
930
+ * @returns {string} CSS-like path (e.g., "form > div:nth-of-type(2) > input")
931
+ */
932
+ getElementPath(element) {
933
+ const path = [];
934
+ let current = element;
935
+ while (current && current !== document.body && path.length < 10) {
936
+ let selector = current.tagName.toLowerCase();
937
+ if (current.className && typeof current.className === "string") {
938
+ const classes = current.className.trim().split(/\s+/).slice(0, 2);
939
+ if (classes.length > 0 && classes[0]) {
940
+ selector += `.${classes.join(".")}`;
643
941
  }
644
942
  }
645
- });
646
- const eventAttributes = ["onclick", "onchange", "oninput", "onfocus", "onblur", "onsubmit"];
647
- eventAttributes.forEach((eventName) => {
648
- const attributeSelector = `[${eventName}]`;
649
- const elements = element.querySelectorAll(attributeSelector);
650
- elements.forEach((elementWithEvent) => {
651
- if (!elementWithEvent || typeof elementWithEvent.getAttribute !== "function") return;
652
- const eventAttr = elementWithEvent.getAttribute(eventName);
653
- if (eventAttr) {
654
- console.warn(
655
- `[Coherent.js] Inline event attribute "${eventName}="${eventAttr}" found but not supported.
656
- For security and CSP compliance, use one of these alternatives:
657
- 1. Function-based handlers: Pass functions directly in virtual DOM
658
- 2. Data-action registry: <button data-action="actionId" data-event="click">
659
- 3. Event registry: <button data-coherent-event="handlerId" data-coherent-event-type="click">
660
- See documentation for details.`
661
- );
662
- }
663
- });
664
- });
665
- const dataActionElements = element.querySelectorAll("[data-action]");
666
- dataActionElements.forEach((actionElement) => {
667
- if (!actionElement || typeof actionElement.getAttribute !== "function") return;
668
- const actionId = actionElement.getAttribute("data-action");
669
- const eventType = actionElement.getAttribute("data-event") || "click";
670
- if (actionId) {
671
- let handlerFunc = null;
672
- if (typeof window !== "undefined" && window.__coherentActionRegistry && window.__coherentActionRegistry[actionId]) {
673
- handlerFunc = window.__coherentActionRegistry[actionId];
674
- } else {
675
- console.warn(`No handler found for action ${actionId}`, window.__coherentActionRegistry);
676
- }
677
- if (handlerFunc && typeof handlerFunc === "function") {
678
- if (typeof actionElement.hasAttribute === "function" && !actionElement.hasAttribute(`data-hydrated-${eventType}`)) {
679
- actionElement.setAttribute(`data-hydrated-${eventType}`, "true");
680
- const handler = (e) => {
681
- try {
682
- let componentElement = actionElement;
683
- while (componentElement && !componentElement.hasAttribute("data-coherent-component")) {
684
- componentElement = componentElement.parentElement;
685
- }
686
- if (componentElement && componentElement.__coherentInstance) {
687
- const instance2 = componentElement.__coherentInstance;
688
- const state = instance2.state || {};
689
- const setState = instance2.setState || (() => {
690
- });
691
- handlerFunc.call(actionElement, e, state, setState);
692
- } else {
693
- handlerFunc.call(actionElement, e);
694
- }
695
- } catch (_error) {
696
- console.warn(`Error executing action handler for ${actionId}:`, _error);
697
- }
698
- };
699
- if (typeof actionElement.addEventListener === "function") {
700
- actionElement.addEventListener(eventType, handler);
701
- if (instance && instance.eventListeners && Array.isArray(instance.eventListeners)) {
702
- instance.eventListeners.push({
703
- element: actionElement,
704
- event: eventType,
705
- handler
706
- });
707
- }
708
- }
709
- }
943
+ if (current.parentElement) {
944
+ const siblings = current.parentElement.querySelectorAll(
945
+ `:scope > ${current.tagName.toLowerCase()}`
946
+ );
947
+ if (siblings.length > 1) {
948
+ const index = Array.from(siblings).indexOf(current);
949
+ selector += `:nth-of-type(${index + 1})`;
710
950
  }
711
951
  }
712
- });
713
- const coherentEventElements = element.querySelectorAll("[data-coherent-event]");
714
- coherentEventElements.forEach((elementWithCoherentEvent) => {
715
- if (!elementWithCoherentEvent || typeof elementWithCoherentEvent.getAttribute !== "function") return;
716
- const eventId = elementWithCoherentEvent.getAttribute("data-coherent-event");
717
- const eventType = elementWithCoherentEvent.getAttribute("data-coherent-event-type");
718
- if (eventId && eventType) {
719
- let handlerFunc = null;
720
- if (typeof window !== "undefined" && window.__coherentEventRegistry && window.__coherentEventRegistry[eventId]) {
721
- handlerFunc = window.__coherentEventRegistry[eventId];
952
+ path.unshift(selector);
953
+ current = current.parentElement;
954
+ }
955
+ return path.join(" > ");
956
+ }
957
+ /**
958
+ * Capture all form input states
959
+ *
960
+ * Iterates through all input, textarea, and select elements,
961
+ * capturing their values, selection state, and checked state.
962
+ *
963
+ * @returns {Map<string, Object>} Map of input keys to their captured state
964
+ */
965
+ captureFormState() {
966
+ this.capturedInputs.clear();
967
+ const inputs = document.querySelectorAll("input, textarea, select");
968
+ for (const input of inputs) {
969
+ const key = this.getInputKey(input);
970
+ const state = {
971
+ value: input.value,
972
+ type: input.type || input.tagName.toLowerCase()
973
+ };
974
+ if (typeof input.selectionStart === "number" && (input.type === "text" || input.type === "search" || input.type === "url" || input.type === "tel" || input.type === "password" || input.tagName.toLowerCase() === "textarea")) {
975
+ state.selectionStart = input.selectionStart;
976
+ state.selectionEnd = input.selectionEnd;
977
+ }
978
+ if (input.type === "checkbox" || input.type === "radio") {
979
+ state.checked = input.checked;
980
+ }
981
+ this.capturedInputs.set(key, state);
982
+ }
983
+ return this.capturedInputs;
984
+ }
985
+ /**
986
+ * Restore form input states after HMR update
987
+ *
988
+ * Finds inputs by their captured keys and restores their values,
989
+ * only if the input type matches (to avoid corrupting data).
990
+ */
991
+ restoreFormState() {
992
+ for (const [key, state] of this.capturedInputs) {
993
+ const inputs = this.findInputsByKey(key);
994
+ for (const input of inputs) {
995
+ const currentType = input.type || input.tagName.toLowerCase();
996
+ if (currentType !== state.type) {
997
+ continue;
722
998
  }
723
- if (handlerFunc && typeof handlerFunc === "function") {
724
- if (typeof elementWithCoherentEvent.hasAttribute === "function" && !elementWithCoherentEvent.hasAttribute(`data-hydrated-${eventType}`)) {
725
- elementWithCoherentEvent.setAttribute(`data-hydrated-${eventType}`, "true");
726
- const handler = (e) => {
727
- try {
728
- const state = instance.state || {};
729
- const setState = instance.setState || (() => {
730
- });
731
- handlerFunc.call(elementWithCoherentEvent, e, state, setState);
732
- } catch (_error) {
733
- console.warn(`Error executing coherent event handler:`, _error);
734
- }
735
- };
736
- if (typeof elementWithCoherentEvent.addEventListener === "function") {
737
- elementWithCoherentEvent.addEventListener(eventType, handler);
738
- if (instance.eventListeners && Array.isArray(instance.eventListeners)) {
739
- instance.eventListeners.push({
740
- element: elementWithCoherentEvent,
741
- event: eventType,
742
- handler
743
- });
744
- }
745
- }
999
+ if (state.checked !== void 0) {
1000
+ input.checked = state.checked;
1001
+ continue;
1002
+ }
1003
+ input.value = state.value;
1004
+ if (state.selectionStart !== void 0 && document.activeElement !== input) {
1005
+ try {
1006
+ input.setSelectionRange(state.selectionStart, state.selectionEnd);
1007
+ } catch {
746
1008
  }
747
1009
  }
748
1010
  }
749
- });
750
- } catch (_error) {
751
- console.warn("Error attaching event listeners:", _error);
1011
+ }
752
1012
  }
753
- }
754
- function handleComponentAction(event, action, target, instance) {
755
- switch (action) {
756
- case "increment":
757
- if (instance.state && instance.state.count !== void 0) {
758
- const step = instance.state.step || 1;
759
- instance.setState({ count: instance.state.count + step });
760
- const countElement = instance.element.querySelector('[data-ref="count"]');
761
- if (countElement) {
762
- countElement.textContent = `Count: ${instance.state.count + step}`;
763
- }
764
- }
765
- break;
766
- case "decrement":
767
- if (instance.state && instance.state.count !== void 0) {
768
- const step = instance.state.step || 1;
769
- instance.setState({ count: instance.state.count - step });
770
- const countElement = instance.element.querySelector('[data-ref="count"]');
771
- if (countElement) {
772
- countElement.textContent = `Count: ${instance.state.count - step}`;
773
- }
1013
+ /**
1014
+ * Find inputs matching a captured key
1015
+ *
1016
+ * @param {string} key - Captured input key
1017
+ * @returns {HTMLElement[]} Array of matching input elements
1018
+ */
1019
+ findInputsByKey(key) {
1020
+ if (key.startsWith("#")) {
1021
+ const id = key.slice(1);
1022
+ const el = document.getElementById(id);
1023
+ return el ? [el] : [];
1024
+ }
1025
+ const nameMatch = key.match(/\[name="([^"]+)"\]/);
1026
+ if (nameMatch) {
1027
+ const name = nameMatch[1];
1028
+ const typeMatch = key.match(/\[type="([^"]+)"\]/);
1029
+ const type = typeMatch ? typeMatch[1] : null;
1030
+ let selector = `[name="${name}"]`;
1031
+ if (type) {
1032
+ selector += `[type="${type}"]`;
774
1033
  }
775
- break;
776
- case "reset":
777
- if (instance.state) {
778
- const initialCount = instance.props.initialCount || 0;
779
- instance.setState({ count: initialCount });
780
- const countElement = instance.element.querySelector('[data-ref="count"]');
781
- if (countElement) {
782
- countElement.textContent = `Count: ${initialCount}`;
1034
+ return Array.from(document.querySelectorAll(selector));
1035
+ }
1036
+ try {
1037
+ const el = document.querySelector(key);
1038
+ return el ? [el] : [];
1039
+ } catch {
1040
+ return [];
1041
+ }
1042
+ }
1043
+ /**
1044
+ * Capture scroll positions for window and scrollable containers
1045
+ *
1046
+ * Captures scroll positions for:
1047
+ * - Window (scrollX/scrollY)
1048
+ * - Elements with [data-coherent-scroll-preserve] attribute
1049
+ * - Elements with overflow that have actual scrolling
1050
+ */
1051
+ captureScrollPositions() {
1052
+ this.scrollPositions.clear();
1053
+ this.scrollPositions.set("window", {
1054
+ top: window.scrollY,
1055
+ left: window.scrollX
1056
+ });
1057
+ const markedScrollables = document.querySelectorAll(
1058
+ "[data-coherent-scroll-preserve]"
1059
+ );
1060
+ for (const el of markedScrollables) {
1061
+ const key = this.getScrollableKey(el);
1062
+ this.scrollPositions.set(key, {
1063
+ top: el.scrollTop,
1064
+ left: el.scrollLeft
1065
+ });
1066
+ }
1067
+ const overflowElements = document.querySelectorAll(
1068
+ '[style*="overflow"], [class]'
1069
+ );
1070
+ for (const el of overflowElements) {
1071
+ const style = window.getComputedStyle(el);
1072
+ const hasOverflow = style.overflow === "auto" || style.overflow === "scroll" || style.overflowY === "auto" || style.overflowY === "scroll" || style.overflowX === "auto" || style.overflowX === "scroll";
1073
+ if (hasOverflow && (el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth)) {
1074
+ const key = this.getScrollableKey(el);
1075
+ if (!this.scrollPositions.has(key)) {
1076
+ this.scrollPositions.set(key, {
1077
+ top: el.scrollTop,
1078
+ left: el.scrollLeft
1079
+ });
783
1080
  }
784
1081
  }
785
- break;
786
- case "changeStep":
787
- const inputElement = event.target;
788
- if (inputElement && inputElement.value) {
789
- const stepValue = parseInt(inputElement.value, 10);
790
- if (!isNaN(stepValue) && stepValue >= 1 && stepValue <= 10) {
791
- instance.setState({ step: stepValue });
792
- const stepElement = instance.element.querySelector('[data-ref="step"]');
793
- if (stepElement) {
794
- stepElement.textContent = `Step: ${stepValue}`;
795
- }
796
- }
1082
+ }
1083
+ return this.scrollPositions;
1084
+ }
1085
+ /**
1086
+ * Generate a stable key for a scrollable element
1087
+ *
1088
+ * @param {HTMLElement} el - Scrollable element
1089
+ * @returns {string} Key for the element
1090
+ */
1091
+ getScrollableKey(el) {
1092
+ if (el.id) {
1093
+ return `#${el.id}`;
1094
+ }
1095
+ const component = el.getAttribute("data-coherent-component");
1096
+ if (component) {
1097
+ return `[data-coherent-component="${component}"]`;
1098
+ }
1099
+ return this.getElementPath(el);
1100
+ }
1101
+ /**
1102
+ * Capture layout snapshot for change detection
1103
+ *
1104
+ * Captures body dimensions and positions of anchor elements
1105
+ * (elements with data-coherent-component attribute).
1106
+ */
1107
+ captureLayout() {
1108
+ this.layoutSnapshot = {
1109
+ bodyHeight: document.body.scrollHeight,
1110
+ bodyWidth: document.body.scrollWidth,
1111
+ anchors: /* @__PURE__ */ new Map()
1112
+ };
1113
+ const components = document.querySelectorAll("[data-coherent-component]");
1114
+ for (const el of components) {
1115
+ const rect = el.getBoundingClientRect();
1116
+ const key = this.getScrollableKey(el);
1117
+ this.layoutSnapshot.anchors.set(key, {
1118
+ top: rect.top,
1119
+ left: rect.left,
1120
+ width: rect.width,
1121
+ height: rect.height
1122
+ });
1123
+ }
1124
+ }
1125
+ /**
1126
+ * Check if layout changed significantly (>50px shift)
1127
+ *
1128
+ * Returns true if:
1129
+ * - Body dimensions changed by more than 50px
1130
+ * - Any anchor element position shifted by more than 50px
1131
+ *
1132
+ * @returns {boolean} True if layout changed significantly
1133
+ */
1134
+ layoutChangedSignificantly() {
1135
+ if (!this.layoutSnapshot) {
1136
+ return false;
1137
+ }
1138
+ const THRESHOLD = 50;
1139
+ const heightDiff = Math.abs(
1140
+ document.body.scrollHeight - this.layoutSnapshot.bodyHeight
1141
+ );
1142
+ const widthDiff = Math.abs(
1143
+ document.body.scrollWidth - this.layoutSnapshot.bodyWidth
1144
+ );
1145
+ if (heightDiff > THRESHOLD || widthDiff > THRESHOLD) {
1146
+ return true;
1147
+ }
1148
+ for (const [key, oldRect] of this.layoutSnapshot.anchors) {
1149
+ const el = this.findElementByKey(key);
1150
+ if (!el) {
1151
+ continue;
797
1152
  }
798
- break;
799
- case "toggle":
800
- const todoIndex = event.target && event.target.getAttribute ? parseInt(event.target.getAttribute("data-todo-index")) : -1;
801
- if (todoIndex >= 0 && instance.state && instance.state.todos && instance.state.todos[todoIndex]) {
802
- const newTodos = [...instance.state.todos];
803
- newTodos[todoIndex].completed = !newTodos[todoIndex].completed;
804
- instance.setState({ todos: newTodos });
1153
+ const newRect = el.getBoundingClientRect();
1154
+ const topDiff = Math.abs(newRect.top - oldRect.top);
1155
+ const leftDiff = Math.abs(newRect.left - oldRect.left);
1156
+ if (topDiff > THRESHOLD || leftDiff > THRESHOLD) {
1157
+ return true;
805
1158
  }
806
- break;
807
- case "add":
808
- if (typeof document !== "undefined" && document.getElementById) {
809
- const input = document.getElementById(`new-todo-${target}`);
810
- if (input && input.value && input.value.trim()) {
811
- if (instance.state && instance.state.todos) {
812
- const newTodos = [
813
- ...instance.state.todos,
814
- { text: input.value.trim(), completed: false }
815
- ];
816
- instance.setState({ todos: newTodos });
817
- input.value = "";
818
- }
819
- }
1159
+ }
1160
+ return false;
1161
+ }
1162
+ /**
1163
+ * Find an element by its scrollable key
1164
+ *
1165
+ * @param {string} key - Element key
1166
+ * @returns {HTMLElement|null} Found element or null
1167
+ */
1168
+ findElementByKey(key) {
1169
+ if (key === "window") {
1170
+ return null;
1171
+ }
1172
+ if (key.startsWith("#")) {
1173
+ return document.getElementById(key.slice(1));
1174
+ }
1175
+ try {
1176
+ return document.querySelector(key);
1177
+ } catch {
1178
+ return null;
1179
+ }
1180
+ }
1181
+ /**
1182
+ * Restore scroll positions if layout hasn't changed significantly
1183
+ *
1184
+ * Logs a message if scroll restoration is skipped due to layout changes.
1185
+ */
1186
+ restoreScrollPositions() {
1187
+ if (this.layoutChangedSignificantly()) {
1188
+ console.log("[HMR] Layout changed significantly, not restoring scroll");
1189
+ return;
1190
+ }
1191
+ const windowPos = this.scrollPositions.get("window");
1192
+ if (windowPos) {
1193
+ window.scrollTo(windowPos.left, windowPos.top);
1194
+ }
1195
+ for (const [key, pos] of this.scrollPositions) {
1196
+ if (key === "window") {
1197
+ continue;
820
1198
  }
821
- break;
822
- default:
823
- if (instance && typeof instance[action] === "function") {
824
- try {
825
- instance[action](event, target);
826
- } catch (_error) {
827
- console.warn(`Error executing custom action ${action}:`, _error);
828
- }
829
- } else {
830
- if (typeof window !== "undefined" && window.__coherentActionRegistry && window.__coherentActionRegistry[action]) {
831
- const handlerFunc = window.__coherentActionRegistry[action];
832
- const state = instance ? instance.state || {} : {};
833
- const setState = instance && instance.setState ? instance.setState.bind(instance) : (() => {
834
- });
835
- try {
836
- handlerFunc(event, state, setState);
837
- } catch (_error) {
838
- console.warn(`Error executing action handler ${action}:`, _error);
839
- }
840
- } else {
841
- }
1199
+ const el = this.findElementByKey(key);
1200
+ if (el) {
1201
+ el.scrollTop = pos.top;
1202
+ el.scrollLeft = pos.left;
842
1203
  }
1204
+ }
1205
+ }
1206
+ /**
1207
+ * Capture all state (form + scroll + layout)
1208
+ *
1209
+ * Convenience method that calls all capture methods.
1210
+ */
1211
+ captureAll() {
1212
+ this.captureFormState();
1213
+ this.captureScrollPositions();
1214
+ this.captureLayout();
1215
+ }
1216
+ /**
1217
+ * Restore all state (form + scroll)
1218
+ *
1219
+ * Convenience method that calls all restore methods.
1220
+ */
1221
+ restoreAll() {
1222
+ this.restoreFormState();
1223
+ this.restoreScrollPositions();
1224
+ }
1225
+ /**
1226
+ * Clear all captured state
1227
+ */
1228
+ clear() {
1229
+ this.capturedInputs.clear();
1230
+ this.scrollPositions.clear();
1231
+ this.layoutSnapshot = null;
843
1232
  }
1233
+ };
1234
+ var stateCapturer = new StateCapturer();
1235
+
1236
+ // src/hmr/overlay.js
1237
+ function escapeHtml(str) {
1238
+ return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
844
1239
  }
845
- function hydrateAll(elements, components, propsArray = []) {
846
- if (elements.length !== components.length) {
847
- throw new Error("Number of elements must match number of components");
848
- }
849
- return elements.map((element, index) => {
850
- const component = components[index];
851
- const props = propsArray[index] || {};
852
- return hydrate(element, component, props);
853
- });
1240
+ function formatCodeFrame(frame, highlightLine, startLine = 1) {
1241
+ if (!frame) return "";
1242
+ const lines = frame.split("\n");
1243
+ return lines.map((content, i) => {
1244
+ const lineNum = startLine + i;
1245
+ const isHighlight = lineNum === highlightLine;
1246
+ return `<div class="line${isHighlight ? " highlight" : ""}">
1247
+ <span class="line-number">${lineNum}</span>
1248
+ <span class="line-content">${escapeHtml(content)}</span>
1249
+ </div>`;
1250
+ }).join("");
854
1251
  }
855
- function hydrateBySelector(selector, component, props = {}) {
856
- if (typeof window === "undefined" || !document.querySelectorAll) {
857
- return [];
1252
+ var EDITOR_URLS = {
1253
+ vscode: (file, line) => `vscode://file/${file}:${line}`,
1254
+ cursor: (file, line) => `cursor://file/${file}:${line}`,
1255
+ "vscode-insiders": (file, line) => `vscode-insiders://file/${file}:${line}`,
1256
+ atom: (file, line) => `atom://core/open/file?filename=${file}&line=${line}`,
1257
+ sublime: (file, line) => `subl://open?url=file://${file}&line=${line}`,
1258
+ webstorm: (file, line) => `webstorm://open?file=${file}&line=${line}`,
1259
+ idea: (file, line) => `idea://open?file=${file}&line=${line}`
1260
+ };
1261
+ var OVERLAY_STYLES = `
1262
+ :host {
1263
+ position: fixed;
1264
+ top: 0;
1265
+ left: 0;
1266
+ width: 100%;
1267
+ height: 100%;
1268
+ z-index: 99999;
1269
+ --bg: #181818;
1270
+ --text: #f8f8f2;
1271
+ --red: #ff5555;
1272
+ --yellow: #f1fa8c;
1273
+ --purple: #bd93f9;
1274
+ --cyan: #8be9fd;
1275
+ --code-bg: #282a36;
1276
+ --line-num: #6272a4;
858
1277
  }
859
- const elements = document.querySelectorAll(selector);
860
- return Array.from(elements).map((element) => hydrate(element, component, props));
861
- }
862
- function enableClientEvents(rootElement = document) {
863
- if (typeof window === "undefined" || !rootElement.querySelectorAll) {
864
- return;
1278
+ .backdrop {
1279
+ position: absolute;
1280
+ top: 0;
1281
+ left: 0;
1282
+ width: 100%;
1283
+ height: 100%;
1284
+ background: rgba(0, 0, 0, 0.66);
865
1285
  }
866
- }
867
- function makeHydratable(component, options = {}) {
868
- const componentName = options.componentName || component.name || "AnonymousComponent";
869
- const hydratableComponent = function(props = {}) {
870
- return component(props);
871
- };
872
- Object.defineProperty(hydratableComponent, "name", {
873
- value: componentName,
874
- writable: false
875
- });
876
- Object.keys(component).forEach((key) => {
877
- hydratableComponent[key] = component[key];
878
- });
879
- if (component.prototype) {
880
- hydratableComponent.prototype = Object.create(component.prototype);
1286
+ .container {
1287
+ position: absolute;
1288
+ top: 50%;
1289
+ left: 50%;
1290
+ transform: translate(-50%, -50%);
1291
+ width: min(800px, 90vw);
1292
+ max-height: 90vh;
1293
+ overflow: auto;
1294
+ background: var(--bg);
1295
+ border-radius: 8px;
1296
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
1297
+ font-family: 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
1298
+ }
1299
+ .header {
1300
+ padding: 16px 20px;
1301
+ background: var(--red);
1302
+ color: white;
1303
+ display: flex;
1304
+ justify-content: space-between;
1305
+ align-items: center;
1306
+ border-radius: 8px 8px 0 0;
1307
+ }
1308
+ .title {
1309
+ font-weight: bold;
1310
+ font-size: 16px;
1311
+ }
1312
+ .close-btn {
1313
+ background: none;
1314
+ border: none;
1315
+ color: white;
1316
+ font-size: 24px;
1317
+ cursor: pointer;
1318
+ padding: 0 8px;
1319
+ line-height: 1;
1320
+ }
1321
+ .close-btn:hover {
1322
+ opacity: 0.8;
1323
+ }
1324
+ .content {
1325
+ padding: 20px;
1326
+ color: var(--text);
1327
+ }
1328
+ .message {
1329
+ font-size: 18px;
1330
+ color: var(--red);
1331
+ margin-bottom: 20px;
1332
+ word-break: break-word;
1333
+ }
1334
+ .file {
1335
+ color: var(--cyan);
1336
+ margin-bottom: 16px;
1337
+ cursor: pointer;
1338
+ text-decoration: underline;
1339
+ }
1340
+ .file:hover {
1341
+ color: var(--purple);
1342
+ }
1343
+ .code-frame {
1344
+ background: var(--code-bg);
1345
+ padding: 16px;
1346
+ border-radius: 4px;
1347
+ overflow-x: auto;
1348
+ font-size: 14px;
1349
+ line-height: 1.5;
1350
+ margin-bottom: 16px;
881
1351
  }
882
- if (component.__wrappedComponent && component.__stateContainer) {
883
- hydratableComponent.__wrappedComponent = component.__wrappedComponent;
884
- hydratableComponent.__stateContainer = component.__stateContainer;
1352
+ .line {
1353
+ display: flex;
885
1354
  }
886
- hydratableComponent.isHydratable = true;
887
- hydratableComponent.hydrationOptions = options;
888
- hydratableComponent.autoHydrate = function(componentRegistry = {}) {
889
- if (!componentRegistry[hydratableComponent.name || "AnonymousComponent"]) {
890
- componentRegistry[hydratableComponent.name || "AnonymousComponent"] = hydratableComponent;
1355
+ .line-number {
1356
+ width: 50px;
1357
+ color: var(--line-num);
1358
+ text-align: right;
1359
+ padding-right: 16px;
1360
+ user-select: none;
1361
+ flex-shrink: 0;
1362
+ }
1363
+ .line-content {
1364
+ flex: 1;
1365
+ white-space: pre;
1366
+ }
1367
+ .line.highlight {
1368
+ background: rgba(255, 85, 85, 0.2);
1369
+ }
1370
+ .line.highlight .line-content {
1371
+ color: var(--red);
1372
+ }
1373
+ .stack {
1374
+ margin-top: 20px;
1375
+ font-size: 12px;
1376
+ color: var(--line-num);
1377
+ white-space: pre-wrap;
1378
+ max-height: 200px;
1379
+ overflow-y: auto;
1380
+ }
1381
+ .tip {
1382
+ margin-top: 16px;
1383
+ padding: 12px;
1384
+ background: rgba(189, 147, 249, 0.1);
1385
+ border-left: 3px solid var(--purple);
1386
+ font-size: 13px;
1387
+ color: var(--text);
1388
+ }
1389
+ .tip strong {
1390
+ color: var(--purple);
1391
+ }
1392
+ `;
1393
+ var ErrorOverlay = class {
1394
+ constructor() {
1395
+ this.overlay = null;
1396
+ this.editor = this._getStoredEditor();
1397
+ this.escapeHandler = null;
1398
+ }
1399
+ /**
1400
+ * Get stored editor preference from localStorage.
1401
+ * @returns {string} Editor name
1402
+ * @private
1403
+ */
1404
+ _getStoredEditor() {
1405
+ try {
1406
+ return localStorage.getItem("coherent-editor") || "vscode";
1407
+ } catch {
1408
+ return "vscode";
891
1409
  }
892
- autoHydrate(componentRegistry);
893
- };
894
- hydratableComponent.isHydratable = true;
895
- hydratableComponent.withHydrationData = function(customProps = {}, customState = null) {
1410
+ }
1411
+ /**
1412
+ * Create the overlay element with Shadow DOM.
1413
+ * @returns {{ host: HTMLElement, shadow: ShadowRoot }} Overlay elements
1414
+ */
1415
+ createOverlay() {
1416
+ if (this.overlay) return this.overlay;
1417
+ const host = document.createElement("div");
1418
+ host.id = "coherent-error-overlay";
1419
+ const shadow = host.attachShadow({ mode: "open" });
1420
+ const style = document.createElement("style");
1421
+ style.textContent = OVERLAY_STYLES;
1422
+ shadow.appendChild(style);
1423
+ this.overlay = { host, shadow };
1424
+ return this.overlay;
1425
+ }
1426
+ /**
1427
+ * Show the error overlay with error details.
1428
+ * @param {Object} error - Error details
1429
+ * @param {string} error.message - Error message
1430
+ * @param {string} [error.file] - File path
1431
+ * @param {number} [error.line] - Line number
1432
+ * @param {number} [error.column] - Column number
1433
+ * @param {string} [error.frame] - Code frame with context
1434
+ * @param {string} [error.stack] - Stack trace
1435
+ */
1436
+ show(error) {
1437
+ const { host, shadow } = this.createOverlay();
1438
+ const existingWrapper = shadow.querySelector(".wrapper");
1439
+ if (existingWrapper) existingWrapper.remove();
1440
+ const frameLines = error.frame ? error.frame.split("\n").length : 0;
1441
+ const startLine = error.line ? Math.max(1, error.line - Math.floor(frameLines / 2)) : 1;
1442
+ const wrapper = document.createElement("div");
1443
+ wrapper.className = "wrapper";
1444
+ wrapper.innerHTML = `
1445
+ <div class="backdrop"></div>
1446
+ <div class="container">
1447
+ <div class="header">
1448
+ <span class="title">HMR Error</span>
1449
+ <button class="close-btn" title="Close (Escape)">&times;</button>
1450
+ </div>
1451
+ <div class="content">
1452
+ <div class="message">${escapeHtml(error.message || "Unknown error")}</div>
1453
+ ${error.file ? `
1454
+ <div class="file" data-file="${escapeHtml(error.file)}" data-line="${error.line || 1}">
1455
+ ${escapeHtml(error.file)}${error.line ? `:${error.line}` : ""}${error.column ? `:${error.column}` : ""}
1456
+ </div>
1457
+ ` : ""}
1458
+ ${error.frame ? `
1459
+ <div class="code-frame">${formatCodeFrame(error.frame, error.line, startLine)}</div>
1460
+ ` : ""}
1461
+ ${error.stack ? `
1462
+ <div class="stack">${escapeHtml(error.stack)}</div>
1463
+ ` : ""}
1464
+ <div class="tip">
1465
+ Press <strong>Escape</strong> or click the X to dismiss.
1466
+ ${error.file ? ` Click the file path to open in ${this.editor}.` : ""}
1467
+ </div>
1468
+ </div>
1469
+ </div>
1470
+ `;
1471
+ shadow.appendChild(wrapper);
1472
+ const closeBtn = wrapper.querySelector(".close-btn");
1473
+ const backdrop = wrapper.querySelector(".backdrop");
1474
+ const fileLink = wrapper.querySelector(".file");
1475
+ closeBtn?.addEventListener("click", () => this.hide());
1476
+ backdrop?.addEventListener("click", () => this.hide());
1477
+ fileLink?.addEventListener("click", (e) => {
1478
+ const target = e.target;
1479
+ const file = target.dataset.file;
1480
+ const line = parseInt(target.dataset.line, 10) || 1;
1481
+ this.openInEditor(file, line);
1482
+ });
1483
+ this.escapeHandler = (e) => {
1484
+ if (e.key === "Escape") this.hide();
1485
+ };
1486
+ document.addEventListener("keydown", this.escapeHandler);
1487
+ if (!host.parentNode) {
1488
+ document.body.appendChild(host);
1489
+ }
1490
+ }
1491
+ /**
1492
+ * Hide and remove the error overlay.
1493
+ */
1494
+ hide() {
1495
+ if (this.overlay?.host.parentNode) {
1496
+ this.overlay.host.parentNode.removeChild(this.overlay.host);
1497
+ }
1498
+ if (this.escapeHandler) {
1499
+ document.removeEventListener("keydown", this.escapeHandler);
1500
+ this.escapeHandler = null;
1501
+ }
1502
+ this.overlay = null;
1503
+ }
1504
+ /**
1505
+ * Open file in configured editor.
1506
+ * @param {string} file - File path
1507
+ * @param {number} [line=1] - Line number
1508
+ */
1509
+ openInEditor(file, line = 1) {
1510
+ const urlGenerator = EDITOR_URLS[this.editor] || EDITOR_URLS.vscode;
1511
+ const url = urlGenerator(file, line);
1512
+ window.open(url, "_self");
1513
+ }
1514
+ /**
1515
+ * Set preferred editor and store in localStorage.
1516
+ * @param {string} editor - Editor name (vscode, cursor, vscode-insiders, atom, sublime, webstorm, idea)
1517
+ */
1518
+ setEditor(editor) {
1519
+ this.editor = editor;
1520
+ try {
1521
+ localStorage.setItem("coherent-editor", editor);
1522
+ } catch {
1523
+ }
1524
+ }
1525
+ };
1526
+ var errorOverlay = new ErrorOverlay();
1527
+
1528
+ // src/hmr/indicator.js
1529
+ var STATUS_COLORS = {
1530
+ connected: "#10b981",
1531
+ // Green
1532
+ disconnected: "#ef4444",
1533
+ // Red
1534
+ reconnecting: "#f59e0b",
1535
+ // Yellow/amber
1536
+ error: "#ef4444"
1537
+ // Red
1538
+ };
1539
+ var STATUS_TITLES = {
1540
+ connected: "HMR: Connected",
1541
+ disconnected: "HMR: Disconnected",
1542
+ reconnecting: "HMR: Reconnecting...",
1543
+ error: "HMR: Error"
1544
+ };
1545
+ var DEFAULT_COLOR = "#666";
1546
+ var ConnectionIndicator = class {
1547
+ constructor() {
1548
+ this.indicator = null;
1549
+ }
1550
+ /**
1551
+ * Create the indicator element if it doesn't exist.
1552
+ * Uses inline styles to avoid external CSS dependencies.
1553
+ */
1554
+ create() {
1555
+ if (this.indicator) return;
1556
+ const el = document.createElement("div");
1557
+ el.id = "coherent-hmr-indicator";
1558
+ el.style.cssText = `
1559
+ position: fixed;
1560
+ bottom: 8px;
1561
+ right: 8px;
1562
+ width: 8px;
1563
+ height: 8px;
1564
+ border-radius: 50%;
1565
+ background: ${DEFAULT_COLOR};
1566
+ z-index: 99998;
1567
+ pointer-events: none;
1568
+ transition: background 0.3s ease;
1569
+ `;
1570
+ el.title = "HMR: Initializing";
1571
+ document.body.appendChild(el);
1572
+ this.indicator = el;
1573
+ }
1574
+ /**
1575
+ * Update the indicator status.
1576
+ * Creates the element if it doesn't exist (lazy initialization).
1577
+ *
1578
+ * @param {string} status - Status string: 'connected', 'disconnected', 'reconnecting', or 'error'
1579
+ */
1580
+ update(status) {
1581
+ if (!this.indicator) {
1582
+ this.create();
1583
+ }
1584
+ const color = STATUS_COLORS[status] || STATUS_COLORS.disconnected;
1585
+ const title = STATUS_TITLES[status] || "HMR: Unknown";
1586
+ this.indicator.style.background = color;
1587
+ this.indicator.title = title;
1588
+ }
1589
+ /**
1590
+ * Remove the indicator from the DOM.
1591
+ */
1592
+ destroy() {
1593
+ if (this.indicator?.parentNode) {
1594
+ this.indicator.parentNode.removeChild(this.indicator);
1595
+ }
1596
+ this.indicator = null;
1597
+ }
1598
+ };
1599
+ var connectionIndicator = new ConnectionIndicator();
1600
+
1601
+ // src/hmr/module-tracker.js
1602
+ var ModuleTracker = class {
1603
+ constructor() {
1604
+ this.modules = /* @__PURE__ */ new Map();
1605
+ this.socket = null;
1606
+ }
1607
+ /**
1608
+ * Set WebSocket reference for invalidation messages
1609
+ * @param {WebSocket|null} socket - WebSocket connection
1610
+ */
1611
+ setSocket(socket) {
1612
+ this.socket = socket;
1613
+ }
1614
+ /**
1615
+ * Create a hot context for a module (Vite-compatible API)
1616
+ *
1617
+ * Returns an object with:
1618
+ * - data: Persistent object that survives HMR updates
1619
+ * - accept(callback): Register self-update handler
1620
+ * - acceptDeps(deps, callback): Register dependency update handler
1621
+ * - dispose(callback): Register cleanup handler called before replacement
1622
+ * - prune(callback): Register handler for when module is removed
1623
+ * - invalidate(message): Signal that module cannot hot-update
1624
+ *
1625
+ * @param {string} moduleId - Unique identifier for the module
1626
+ * @returns {Object} Hot context object
1627
+ */
1628
+ createHotContext(moduleId) {
1629
+ let moduleData = this.modules.get(moduleId);
1630
+ if (!moduleData) {
1631
+ moduleData = {
1632
+ accept: null,
1633
+ acceptDeps: null,
1634
+ dispose: null,
1635
+ prune: null,
1636
+ data: {}
1637
+ };
1638
+ this.modules.set(moduleId, moduleData);
1639
+ }
1640
+ const tracker = this;
896
1641
  return {
897
- render: function(props = {}) {
898
- const mergedProps = { ...customProps, ...props };
899
- const result = hydratableComponent(mergedProps);
900
- const hydrationData = hydratableComponent.getHydrationData(mergedProps, customState);
901
- if (result && typeof result === "object" && !Array.isArray(result)) {
902
- const tagName = Object.keys(result)[0];
903
- const elementProps = result[tagName];
904
- if (elementProps && typeof elementProps === "object") {
905
- Object.keys(hydrationData.hydrationAttributes).forEach((attr) => {
906
- const value = hydrationData.hydrationAttributes[attr];
907
- if (value !== null) {
908
- elementProps[attr] = value;
909
- }
910
- });
911
- }
1642
+ /**
1643
+ * Persistent data object that survives HMR updates.
1644
+ * Use this to preserve state across module replacements.
1645
+ */
1646
+ get data() {
1647
+ return moduleData.data;
1648
+ },
1649
+ /**
1650
+ * Accept self updates.
1651
+ * Called when this module is updated and can handle its own replacement.
1652
+ *
1653
+ * @param {Function} [callback] - Optional callback receiving the new module
1654
+ */
1655
+ accept(callback) {
1656
+ moduleData.accept = callback || (() => {
1657
+ });
1658
+ },
1659
+ /**
1660
+ * Accept dependency updates.
1661
+ * Called when one of the specified dependencies is updated.
1662
+ *
1663
+ * @param {string|string[]} deps - Dependency module ID(s)
1664
+ * @param {Function} callback - Callback receiving updated dependencies
1665
+ */
1666
+ acceptDeps(deps, callback) {
1667
+ const depsArray = Array.isArray(deps) ? deps : [deps];
1668
+ moduleData.acceptDeps = { deps: depsArray, callback };
1669
+ },
1670
+ /**
1671
+ * Register disposal callback.
1672
+ * Called before the module is replaced, receives the data object
1673
+ * to allow saving state for the next version.
1674
+ *
1675
+ * @param {Function} callback - Cleanup handler, receives data object
1676
+ */
1677
+ dispose(callback) {
1678
+ moduleData.dispose = callback;
1679
+ },
1680
+ /**
1681
+ * Register prune callback.
1682
+ * Called when the module is completely removed from the module graph.
1683
+ *
1684
+ * @param {Function} callback - Prune handler
1685
+ */
1686
+ prune(callback) {
1687
+ moduleData.prune = callback;
1688
+ },
1689
+ /**
1690
+ * Invalidate this module.
1691
+ * Signals that the module cannot be hot-updated and should propagate
1692
+ * the update to its importers.
1693
+ *
1694
+ * @param {string} [message] - Optional message explaining why
1695
+ */
1696
+ invalidate(message) {
1697
+ const WS_OPEN = typeof WebSocket !== "undefined" ? WebSocket.OPEN : 1;
1698
+ if (tracker.socket?.readyState === WS_OPEN) {
1699
+ tracker.socket.send(JSON.stringify({
1700
+ type: "invalidate",
1701
+ moduleId,
1702
+ message
1703
+ }));
912
1704
  }
913
- return result;
1705
+ console.log(`[HMR] Module ${moduleId} invalidated${message ? `: ${message}` : ""}`);
914
1706
  }
915
1707
  };
916
- };
917
- hydratableComponent.getHydrationData = function(props = {}, state = null) {
918
- return {
919
- componentName,
920
- props,
921
- initialState: options.initialState,
922
- // Add data attributes for hydration
923
- hydrationAttributes: {
924
- "data-coherent-component": componentName,
925
- "data-coherent-state": state ? JSON.stringify(state) : options.initialState ? JSON.stringify(options.initialState) : null,
926
- "data-coherent-props": Object.keys(props).length > 0 ? JSON.stringify(props) : null
1708
+ }
1709
+ /**
1710
+ * Check if a module can be hot-updated
1711
+ *
1712
+ * Returns true if the module has registered an accept handler.
1713
+ *
1714
+ * @param {string} moduleId - Module identifier
1715
+ * @returns {boolean} True if module accepts HMR updates
1716
+ */
1717
+ canHotUpdate(moduleId) {
1718
+ const moduleData = this.modules.get(moduleId);
1719
+ return !!(moduleData?.accept || moduleData?.acceptDeps);
1720
+ }
1721
+ /**
1722
+ * Check if a module is an HMR boundary
1723
+ *
1724
+ * A module is considered a boundary if:
1725
+ * - It has an accept handler registered
1726
+ * - It exports __hmrBoundary = true
1727
+ * - It is associated with a data-coherent-component element
1728
+ *
1729
+ * @param {string} moduleId - Module identifier
1730
+ * @param {Object} [moduleExports] - Optional module exports to check for __hmrBoundary
1731
+ * @returns {boolean} True if module is an HMR boundary
1732
+ */
1733
+ isHmrBoundary(moduleId, moduleExports) {
1734
+ if (this.canHotUpdate(moduleId)) {
1735
+ return true;
1736
+ }
1737
+ if (moduleExports?.__hmrBoundary === true) {
1738
+ return true;
1739
+ }
1740
+ const componentName = this.extractComponentName(moduleId);
1741
+ if (componentName && typeof document !== "undefined") {
1742
+ const hasComponent = document.querySelector(
1743
+ `[data-coherent-component="${componentName}"]`
1744
+ );
1745
+ if (hasComponent) {
1746
+ return true;
927
1747
  }
928
- };
929
- };
930
- hydratableComponent.renderWithHydration = function(props = {}) {
931
- const result = hydratableComponent(props);
932
- let state = null;
933
- if (hydratableComponent.__wrappedComponent && hydratableComponent.__stateContainer) {
1748
+ }
1749
+ return false;
1750
+ }
1751
+ /**
1752
+ * Extract a potential component name from module path
1753
+ *
1754
+ * @param {string} moduleId - Module path
1755
+ * @returns {string|null} Component name or null
1756
+ * @private
1757
+ */
1758
+ extractComponentName(moduleId) {
1759
+ const match = moduleId.match(/\/([^/]+?)(?:\.[^.]+)?$/);
1760
+ if (match) {
1761
+ return match[1];
1762
+ }
1763
+ return null;
1764
+ }
1765
+ /**
1766
+ * Execute dispose callback for a module
1767
+ *
1768
+ * Calls the registered dispose handler with the data object,
1769
+ * allowing the module to save state for the next version.
1770
+ *
1771
+ * @param {string} moduleId - Module identifier
1772
+ * @returns {Object|null} The data object (for passing to next version)
1773
+ */
1774
+ executeDispose(moduleId) {
1775
+ const moduleData = this.modules.get(moduleId);
1776
+ if (!moduleData) {
1777
+ return null;
1778
+ }
1779
+ if (typeof moduleData.dispose === "function") {
934
1780
  try {
935
- state = hydratableComponent.__stateContainer.getState();
936
- } catch (e) {
937
- console.warn("Could not get component state:", e);
1781
+ moduleData.dispose(moduleData.data);
1782
+ } catch (err) {
1783
+ console.error(`[HMR] Error in dispose handler for ${moduleId}:`, err);
938
1784
  }
939
1785
  }
940
- const hydrationData = hydratableComponent.getHydrationData(props, state);
941
- if (result && typeof result === "object" && !Array.isArray(result)) {
942
- const tagName = Object.keys(result)[0];
943
- const elementProps = result[tagName];
944
- if (elementProps && typeof elementProps === "object") {
945
- Object.keys(hydrationData.hydrationAttributes).forEach((attr) => {
946
- const value = hydrationData.hydrationAttributes[attr];
947
- if (value !== null) {
948
- elementProps[attr] = value;
949
- }
950
- });
951
- }
1786
+ return moduleData.data;
1787
+ }
1788
+ /**
1789
+ * Execute accept callback for a module
1790
+ *
1791
+ * Calls the registered accept handler with the new module.
1792
+ *
1793
+ * @param {string} moduleId - Module identifier
1794
+ * @param {Object} [newModule] - The newly imported module
1795
+ * @returns {boolean} True if accept handler was called
1796
+ */
1797
+ executeAccept(moduleId, newModule) {
1798
+ const moduleData = this.modules.get(moduleId);
1799
+ if (!moduleData?.accept) {
1800
+ return false;
1801
+ }
1802
+ try {
1803
+ moduleData.accept(newModule);
1804
+ return true;
1805
+ } catch (err) {
1806
+ console.error(`[HMR] Error in accept handler for ${moduleId}:`, err);
1807
+ return false;
1808
+ }
1809
+ }
1810
+ /**
1811
+ * Execute acceptDeps callback for a module
1812
+ *
1813
+ * Calls the registered acceptDeps handler with the updated dependencies.
1814
+ *
1815
+ * @param {string} moduleId - Module identifier
1816
+ * @param {Object} updatedDeps - Map of dependency moduleId -> new module
1817
+ * @returns {boolean} True if acceptDeps handler was called
1818
+ */
1819
+ executeAcceptDeps(moduleId, updatedDeps) {
1820
+ const moduleData = this.modules.get(moduleId);
1821
+ if (!moduleData?.acceptDeps) {
1822
+ return false;
1823
+ }
1824
+ try {
1825
+ const { deps, callback } = moduleData.acceptDeps;
1826
+ const modules = deps.map((dep) => updatedDeps[dep]);
1827
+ callback(modules);
1828
+ return true;
1829
+ } catch (err) {
1830
+ console.error(`[HMR] Error in acceptDeps handler for ${moduleId}:`, err);
1831
+ return false;
1832
+ }
1833
+ }
1834
+ /**
1835
+ * Execute prune callback for a module
1836
+ *
1837
+ * Called when a module is removed from the module graph.
1838
+ *
1839
+ * @param {string} moduleId - Module identifier
1840
+ */
1841
+ executePrune(moduleId) {
1842
+ const moduleData = this.modules.get(moduleId);
1843
+ if (!moduleData?.prune) {
1844
+ return;
952
1845
  }
1846
+ try {
1847
+ moduleData.prune();
1848
+ } catch (err) {
1849
+ console.error(`[HMR] Error in prune handler for ${moduleId}:`, err);
1850
+ }
1851
+ this.modules.delete(moduleId);
1852
+ }
1853
+ /**
1854
+ * Check if module is registered
1855
+ *
1856
+ * @param {string} moduleId - Module identifier
1857
+ * @returns {boolean} True if module is registered
1858
+ */
1859
+ hasModule(moduleId) {
1860
+ return this.modules.has(moduleId);
1861
+ }
1862
+ /**
1863
+ * Get module data (for testing/debugging)
1864
+ *
1865
+ * @param {string} moduleId - Module identifier
1866
+ * @returns {Object|null} Module data or null
1867
+ */
1868
+ getModuleData(moduleId) {
1869
+ return this.modules.get(moduleId) || null;
1870
+ }
1871
+ /**
1872
+ * Clear all module registrations (for testing)
1873
+ */
1874
+ clear() {
1875
+ this.modules.clear();
1876
+ }
1877
+ };
1878
+ var moduleTracker = new ModuleTracker();
1879
+ function createHotContext(moduleId) {
1880
+ return moduleTracker.createHotContext(moduleId);
1881
+ }
1882
+
1883
+ // src/hmr/client.js
1884
+ function parseErrorLocation(error) {
1885
+ const result = { file: null, line: null, column: null };
1886
+ if (!error.stack) {
953
1887
  return result;
954
- };
955
- return hydratableComponent;
1888
+ }
1889
+ const patterns = [
1890
+ /at\s+.*?\((.+?):(\d+):(\d+)\)/,
1891
+ // Chrome/Node with parens
1892
+ /at\s+(.+?):(\d+):(\d+)/,
1893
+ // Chrome/Node without parens
1894
+ /@(.+?):(\d+):(\d+)/,
1895
+ // Firefox
1896
+ /^(.+?):(\d+):(\d+)/
1897
+ // Safari
1898
+ ];
1899
+ const lines = error.stack.split("\n");
1900
+ for (const line of lines) {
1901
+ for (const pattern of patterns) {
1902
+ const match = line.match(pattern);
1903
+ if (match) {
1904
+ result.file = match[1];
1905
+ result.line = parseInt(match[2], 10);
1906
+ result.column = parseInt(match[3], 10);
1907
+ return result;
1908
+ }
1909
+ }
1910
+ }
1911
+ return result;
956
1912
  }
957
- function autoHydrate(componentRegistry = {}) {
958
- if (typeof window === "undefined" || typeof document === "undefined") {
959
- return;
1913
+ var HMRClient = class {
1914
+ constructor() {
1915
+ this.socket = null;
1916
+ this.connected = false;
1917
+ this.reconnectAttempts = 0;
1918
+ this.maxReconnectAttempts = 10;
1919
+ this.reconnectDelay = 1e3;
1920
+ this.hadDisconnect = false;
1921
+ this.reconnectTimeout = null;
1922
+ this.initialized = false;
960
1923
  }
961
- if (componentRegistry === window) {
962
- console.warn("\u26A0\uFE0F Component registry is the window object! This suggests the registry was not properly initialized.");
963
- componentRegistry = window.componentRegistry || {};
964
- }
965
- window.__coherentEventRegistry = window.__coherentEventRegistry || {};
966
- window.__coherentActionRegistry = window.__coherentActionRegistry || {};
967
- const hydrateComponents = () => {
968
- const hydrateableElements = document.querySelectorAll("[data-coherent-component]");
969
- hydrateableElements.forEach((element) => {
970
- const componentName = element.getAttribute("data-coherent-component");
971
- let component = componentRegistry[componentName];
972
- if (!component) {
973
- for (const comp of Object.values(componentRegistry)) {
974
- if (comp && comp.isHydratable) {
975
- component = comp;
976
- break;
977
- }
1924
+ /**
1925
+ * Connect to the dev server WebSocket
1926
+ *
1927
+ * Establishes WebSocket connection with automatic reconnection using
1928
+ * exponential backoff with jitter.
1929
+ *
1930
+ * @returns {void}
1931
+ */
1932
+ connect() {
1933
+ if (typeof window === "undefined") {
1934
+ return;
1935
+ }
1936
+ if (this.reconnectTimeout !== null) {
1937
+ clearTimeout(this.reconnectTimeout);
1938
+ this.reconnectTimeout = null;
1939
+ }
1940
+ try {
1941
+ const protocol = location.protocol === "https:" ? "wss" : "ws";
1942
+ const wsUrl = `${protocol}://${location.host}`;
1943
+ this.socket = new WebSocket(wsUrl);
1944
+ moduleTracker.setSocket(this.socket);
1945
+ this.socket.addEventListener("open", () => {
1946
+ console.log("[HMR] Connected");
1947
+ this.connected = true;
1948
+ this.reconnectAttempts = 0;
1949
+ connectionIndicator.update("connected");
1950
+ this.socket.send(JSON.stringify({ type: "connected" }));
1951
+ if (this.hadDisconnect) {
1952
+ console.log("[HMR] Reconnected after disconnect, reloading page");
1953
+ setTimeout(() => location.reload(), 200);
1954
+ return;
978
1955
  }
979
- }
980
- if (!component) {
981
- console.error(`\u274C Component ${componentName} not found in registry`);
982
- return;
983
- }
984
- if (component) {
1956
+ });
1957
+ this.socket.addEventListener("close", () => {
1958
+ this.connected = false;
1959
+ this.hadDisconnect = true;
1960
+ connectionIndicator.update("disconnected");
1961
+ moduleTracker.setSocket(null);
1962
+ this.scheduleReconnect();
1963
+ });
1964
+ this.socket.addEventListener("error", (event) => {
1965
+ console.warn("[HMR] WebSocket error:", event);
1966
+ connectionIndicator.update("error");
985
1967
  try {
986
- const propsAttr = element.getAttribute("data-coherent-props");
987
- const props = propsAttr ? JSON.parse(propsAttr) : {};
988
- const stateAttr = element.getAttribute("data-coherent-state");
989
- const initialState = stateAttr ? JSON.parse(stateAttr) : null;
990
- const instance = hydrate(element, component, props, { initialState });
991
- if (instance) {
992
- } else {
993
- console.warn(`\u274C Failed to hydrate component: ${componentName}`);
994
- }
995
- } catch (_error) {
996
- console.error(`\u274C Failed to auto-hydrate component ${componentName}:`, _error);
1968
+ this.socket.close();
1969
+ } catch {
997
1970
  }
1971
+ });
1972
+ this.socket.addEventListener("message", (event) => {
1973
+ this.handleMessage(event);
1974
+ });
1975
+ } catch (error) {
1976
+ console.warn("[HMR] Failed to connect:", error);
1977
+ this.scheduleReconnect();
1978
+ }
1979
+ }
1980
+ /**
1981
+ * Schedule a reconnection attempt with exponential backoff
1982
+ *
1983
+ * @private
1984
+ */
1985
+ scheduleReconnect() {
1986
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
1987
+ console.warn("[HMR] Max reconnection attempts reached");
1988
+ connectionIndicator.update("disconnected");
1989
+ return;
1990
+ }
1991
+ connectionIndicator.update("reconnecting");
1992
+ const delay = Math.min(
1993
+ this.reconnectDelay * Math.pow(2, this.reconnectAttempts) + Math.random() * 1e3,
1994
+ 3e4
1995
+ );
1996
+ this.reconnectAttempts++;
1997
+ console.log(`[HMR] Reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
1998
+ this.reconnectTimeout = setTimeout(() => {
1999
+ this.reconnectTimeout = null;
2000
+ this.connect();
2001
+ }, delay);
2002
+ }
2003
+ /**
2004
+ * Handle incoming WebSocket message
2005
+ *
2006
+ * @param {MessageEvent} event - WebSocket message event
2007
+ * @private
2008
+ */
2009
+ handleMessage(event) {
2010
+ let data;
2011
+ try {
2012
+ data = JSON.parse(event.data);
2013
+ } catch {
2014
+ return;
2015
+ }
2016
+ console.log("[HMR] message", data.type, data.filePath || data.webPath || "");
2017
+ switch (data.type) {
2018
+ case "connected":
2019
+ break;
2020
+ case "hmr-full-reload":
2021
+ case "reload":
2022
+ console.warn("[HMR] Server requested full reload");
2023
+ location.reload();
2024
+ break;
2025
+ case "hmr-component-update":
2026
+ case "hmr-update":
2027
+ this.handleUpdate(data);
2028
+ break;
2029
+ case "hmr-error":
2030
+ this.showError(data.error || data);
2031
+ break;
2032
+ case "preview-update":
2033
+ break;
2034
+ default:
2035
+ break;
2036
+ }
2037
+ }
2038
+ /**
2039
+ * Handle module update
2040
+ *
2041
+ * Orchestrates the full HMR update cycle:
2042
+ * 1. Capture form/scroll state
2043
+ * 2. Execute dispose handlers
2044
+ * 3. Clean up module resources
2045
+ * 4. Re-import module
2046
+ * 5. Execute accept handlers
2047
+ * 6. Restore state
2048
+ *
2049
+ * @param {Object} data - Update message data
2050
+ * @param {string} [data.filePath] - File path that changed
2051
+ * @param {string} [data.webPath] - Web-accessible path
2052
+ * @param {string} [data.updateType] - Type of update (component, style, etc.)
2053
+ */
2054
+ async handleUpdate(data) {
2055
+ const filePath = data.webPath || data.filePath || "";
2056
+ const moduleId = filePath;
2057
+ try {
2058
+ stateCapturer.captureAll();
2059
+ if (moduleTracker.hasModule(moduleId)) {
2060
+ moduleTracker.executeDispose(moduleId);
998
2061
  }
999
- });
1000
- enableClientEvents();
1001
- };
1002
- if (document.readyState === "loading") {
1003
- document.addEventListener("DOMContentLoaded", hydrateComponents);
1004
- } else {
1005
- hydrateComponents();
2062
+ if (cleanupTracker.hasResources(moduleId)) {
2063
+ cleanupTracker.checkForLeaks(moduleId);
2064
+ cleanupTracker.cleanup(moduleId);
2065
+ }
2066
+ const importPath = filePath.startsWith("/") ? filePath : `/${filePath}`;
2067
+ const newModule = await import(`${importPath}?t=${Date.now()}`);
2068
+ const accepted = moduleTracker.canHotUpdate(moduleId);
2069
+ if (accepted) {
2070
+ moduleTracker.executeAccept(moduleId, newModule);
2071
+ } else {
2072
+ await this.fallbackHydrate();
2073
+ }
2074
+ stateCapturer.restoreAll();
2075
+ errorOverlay.hide();
2076
+ console.log(`[HMR] Updated: ${data.updateType || "module"} ${filePath}`);
2077
+ } catch (error) {
2078
+ this.handleUpdateError(error, filePath);
2079
+ }
1006
2080
  }
1007
- }
2081
+ /**
2082
+ * Fall back to autoHydrate for non-HMR-aware modules
2083
+ *
2084
+ * @private
2085
+ */
2086
+ async fallbackHydrate() {
2087
+ try {
2088
+ const { autoHydrate } = await import("../hydration.js");
2089
+ if (typeof window !== "undefined" && window.componentRegistry) {
2090
+ autoHydrate(window.componentRegistry);
2091
+ } else {
2092
+ autoHydrate();
2093
+ }
2094
+ } catch {
2095
+ console.warn("[HMR] autoHydrate not available, component may need manual refresh");
2096
+ }
2097
+ }
2098
+ /**
2099
+ * Handle update error
2100
+ *
2101
+ * @param {Error} error - Error that occurred
2102
+ * @param {string} filePath - File that was being updated
2103
+ * @private
2104
+ */
2105
+ handleUpdateError(error, filePath) {
2106
+ console.error("[HMR] Update failed:", error);
2107
+ const location2 = parseErrorLocation(error);
2108
+ const errorDetails = {
2109
+ message: error.message || "Unknown error during HMR update",
2110
+ file: location2.file || filePath,
2111
+ line: location2.line,
2112
+ column: location2.column,
2113
+ stack: error.stack
2114
+ };
2115
+ this.showError(errorDetails);
2116
+ }
2117
+ /**
2118
+ * Show error overlay
2119
+ *
2120
+ * @param {Object} error - Error details
2121
+ */
2122
+ showError(error) {
2123
+ errorOverlay.show(error);
2124
+ }
2125
+ /**
2126
+ * Hide error overlay
2127
+ */
2128
+ hideError() {
2129
+ errorOverlay.hide();
2130
+ }
2131
+ /**
2132
+ * Initialize HMR client
2133
+ *
2134
+ * Guards against double initialization and connects to dev server.
2135
+ *
2136
+ * @returns {void}
2137
+ */
2138
+ initialize() {
2139
+ if (typeof window === "undefined") {
2140
+ return;
2141
+ }
2142
+ if (window.__coherent_hmr_initialized || this.initialized) {
2143
+ return;
2144
+ }
2145
+ window.__coherent_hmr_initialized = true;
2146
+ this.initialized = true;
2147
+ this.connect();
2148
+ }
2149
+ /**
2150
+ * Disconnect and clean up
2151
+ *
2152
+ * @returns {void}
2153
+ */
2154
+ disconnect() {
2155
+ if (this.reconnectTimeout !== null) {
2156
+ clearTimeout(this.reconnectTimeout);
2157
+ this.reconnectTimeout = null;
2158
+ }
2159
+ if (this.socket) {
2160
+ try {
2161
+ this.socket.close();
2162
+ } catch {
2163
+ }
2164
+ this.socket = null;
2165
+ }
2166
+ this.connected = false;
2167
+ moduleTracker.setSocket(null);
2168
+ connectionIndicator.destroy();
2169
+ }
2170
+ /**
2171
+ * Check if client is connected
2172
+ *
2173
+ * @returns {boolean} True if connected
2174
+ */
2175
+ isConnected() {
2176
+ return this.connected;
2177
+ }
2178
+ };
2179
+ var hmrClient = new HMRClient();
1008
2180
  export {
1009
- autoHydrate,
1010
- enableClientEvents,
2181
+ CleanupTracker,
2182
+ ConnectionIndicator,
2183
+ ErrorOverlay,
2184
+ EventDelegation,
2185
+ HMRClient,
2186
+ HandlerRegistry,
2187
+ ModuleTracker,
2188
+ StateCapturer,
2189
+ cleanupTracker,
2190
+ connectionIndicator,
2191
+ createHotContext,
2192
+ deserializeState,
2193
+ detectMismatch,
2194
+ errorOverlay,
2195
+ eventDelegation,
2196
+ extractState,
2197
+ formatPath,
2198
+ handlerRegistry,
2199
+ hmrClient,
1011
2200
  hydrate,
1012
- hydrateAll,
1013
- hydrateBySelector,
1014
- makeHydratable,
1015
- registerEventHandler
2201
+ moduleTracker,
2202
+ reportMismatches,
2203
+ serializeState,
2204
+ serializeStateWithWarning,
2205
+ stateCapturer,
2206
+ wrapEvent
1016
2207
  };
1017
2208
  //# sourceMappingURL=index.js.map