tova 0.1.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,240 @@
1
+ // Universal I/O — read() / write() for Tova
2
+ // Format inferred from file extension. Zero config.
3
+
4
+ import { Table } from './table.js';
5
+
6
+ // ── CSV Parsing ───────────────────────────────────────
7
+
8
+ function parseCSV(text, opts = {}) {
9
+ const delimiter = opts.delimiter || ',';
10
+ const hasHeader = opts.header !== false;
11
+ const lines = text.split('\n').filter(l => l.trim());
12
+ if (lines.length === 0) return new Table([]);
13
+
14
+ const parseLine = (line) => {
15
+ const fields = [];
16
+ let current = '';
17
+ let inQuotes = false;
18
+ for (let i = 0; i < line.length; i++) {
19
+ const ch = line[i];
20
+ if (inQuotes) {
21
+ if (ch === '"' && line[i + 1] === '"') {
22
+ current += '"';
23
+ i++;
24
+ } else if (ch === '"') {
25
+ inQuotes = false;
26
+ } else {
27
+ current += ch;
28
+ }
29
+ } else {
30
+ if (ch === '"') {
31
+ inQuotes = true;
32
+ } else if (ch === delimiter) {
33
+ fields.push(current.trim());
34
+ current = '';
35
+ } else {
36
+ current += ch;
37
+ }
38
+ }
39
+ }
40
+ fields.push(current.trim());
41
+ return fields;
42
+ };
43
+
44
+ let headers;
45
+ let dataStart;
46
+ if (hasHeader) {
47
+ headers = parseLine(lines[0]);
48
+ dataStart = 1;
49
+ } else {
50
+ const firstRow = parseLine(lines[0]);
51
+ headers = firstRow.map((_, i) => `col_${i}`);
52
+ dataStart = 0;
53
+ }
54
+
55
+ const rows = [];
56
+ for (let i = dataStart; i < lines.length; i++) {
57
+ const fields = parseLine(lines[i]);
58
+ const row = {};
59
+ for (let j = 0; j < headers.length; j++) {
60
+ let val = fields[j] ?? null;
61
+ // Auto-detect types
62
+ if (val !== null && val !== '') {
63
+ if (/^-?\d+$/.test(val)) val = parseInt(val, 10);
64
+ else if (/^-?\d*\.\d+$/.test(val)) val = parseFloat(val);
65
+ else if (val === 'true') val = true;
66
+ else if (val === 'false') val = false;
67
+ else if (val === 'null' || val === 'nil') val = null;
68
+ } else if (val === '') {
69
+ val = null;
70
+ }
71
+ row[headers[j]] = val;
72
+ }
73
+ rows.push(row);
74
+ }
75
+
76
+ return new Table(rows, headers);
77
+ }
78
+
79
+ // ── CSV Writing ───────────────────────────────────────
80
+
81
+ function toCSV(table, opts = {}) {
82
+ const delimiter = opts.delimiter || ',';
83
+ const cols = table._columns;
84
+ const lines = [cols.join(delimiter)];
85
+ for (const row of table._rows) {
86
+ const cells = cols.map(c => {
87
+ const val = row[c];
88
+ if (val === null || val === undefined) return '';
89
+ const str = String(val);
90
+ if (str.includes(delimiter) || str.includes('"') || str.includes('\n')) {
91
+ return `"${str.replace(/"/g, '""')}"`;
92
+ }
93
+ return str;
94
+ });
95
+ lines.push(cells.join(delimiter));
96
+ }
97
+ return lines.join('\n');
98
+ }
99
+
100
+ // ── JSONL Parsing ─────────────────────────────────────
101
+
102
+ function parseJSONL(text) {
103
+ const lines = text.split('\n').filter(l => l.trim());
104
+ const rows = lines.map(l => JSON.parse(l));
105
+ return new Table(rows);
106
+ }
107
+
108
+ function toJSONL(table) {
109
+ return table._rows.map(r => JSON.stringify(r)).join('\n');
110
+ }
111
+
112
+ // ── read() — Universal Reader ─────────────────────────
113
+
114
+ export async function read(sourceOrDb, queryOrOpts, opts = {}) {
115
+ // Database query: read(db, "SELECT ...")
116
+ if (sourceOrDb && typeof sourceOrDb === 'object' && sourceOrDb.query) {
117
+ const result = await sourceOrDb.query(queryOrOpts);
118
+ return new Table(result);
119
+ }
120
+
121
+ const source = sourceOrDb;
122
+ if (typeof source !== 'string') {
123
+ throw new Error(`read() expects a file path or URL string, got ${typeof source}`);
124
+ }
125
+
126
+ const options = typeof queryOrOpts === 'object' ? queryOrOpts : opts;
127
+
128
+ // URL fetch
129
+ if (source.startsWith('http://') || source.startsWith('https://')) {
130
+ const response = await fetch(source);
131
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
132
+ const contentType = response.headers.get('content-type') || '';
133
+
134
+ if (contentType.includes('json')) {
135
+ const data = await response.json();
136
+ if (Array.isArray(data)) return new Table(data);
137
+ return data;
138
+ }
139
+
140
+ const text = await response.text();
141
+ // Try to detect format from URL
142
+ if (source.endsWith('.csv')) return parseCSV(text, options);
143
+ if (source.endsWith('.jsonl') || source.endsWith('.ndjson')) return parseJSONL(text);
144
+ if (source.endsWith('.tsv')) return parseCSV(text, { ...options, delimiter: '\t' });
145
+
146
+ // Try JSON first
147
+ try {
148
+ const data = JSON.parse(text);
149
+ if (Array.isArray(data)) return new Table(data);
150
+ return data;
151
+ } catch {
152
+ return parseCSV(text, options);
153
+ }
154
+ }
155
+
156
+ // File read
157
+ const fs = await import('fs');
158
+ const path = await import('path');
159
+
160
+ const ext = path.extname(source).toLowerCase();
161
+ const text = fs.readFileSync(source, 'utf-8');
162
+
163
+ switch (ext) {
164
+ case '.csv':
165
+ return parseCSV(text, options);
166
+ case '.tsv':
167
+ return parseCSV(text, { ...options, delimiter: '\t' });
168
+ case '.json':
169
+ const data = JSON.parse(text);
170
+ if (Array.isArray(data)) return new Table(data);
171
+ return data;
172
+ case '.jsonl':
173
+ case '.ndjson':
174
+ return parseJSONL(text);
175
+ default:
176
+ // Try JSON, then CSV
177
+ try {
178
+ const d = JSON.parse(text);
179
+ if (Array.isArray(d)) return new Table(d);
180
+ return d;
181
+ } catch {
182
+ return parseCSV(text, options);
183
+ }
184
+ }
185
+ }
186
+
187
+ // ── write() — Universal Writer ────────────────────────
188
+
189
+ export async function write(data, destination, opts = {}) {
190
+ const fs = await import('fs');
191
+ const path = await import('path');
192
+
193
+ const ext = path.extname(destination).toLowerCase();
194
+ const isTable = data instanceof Table;
195
+ const tableData = isTable ? data : (Array.isArray(data) ? new Table(data) : null);
196
+
197
+ let content;
198
+ switch (ext) {
199
+ case '.csv':
200
+ if (!tableData) throw new Error('write() to CSV requires table/array data');
201
+ content = toCSV(tableData, opts);
202
+ break;
203
+ case '.tsv':
204
+ if (!tableData) throw new Error('write() to TSV requires table/array data');
205
+ content = toCSV(tableData, { ...opts, delimiter: '\t' });
206
+ break;
207
+ case '.json':
208
+ content = JSON.stringify(isTable ? data._rows : data, null, 2);
209
+ break;
210
+ case '.jsonl':
211
+ case '.ndjson':
212
+ if (!tableData) throw new Error('write() to JSONL requires table/array data');
213
+ content = toJSONL(tableData);
214
+ break;
215
+ default:
216
+ content = JSON.stringify(isTable ? data._rows : data, null, 2);
217
+ }
218
+
219
+ if (opts.append) {
220
+ fs.appendFileSync(destination, content + '\n', 'utf-8');
221
+ } else {
222
+ fs.writeFileSync(destination, content, 'utf-8');
223
+ }
224
+ }
225
+
226
+ // ── stream() — Streaming Reader ───────────────────────
227
+
228
+ export async function* stream(source, opts = {}) {
229
+ const batch = opts.batch || 1000;
230
+ const fs = await import('fs');
231
+
232
+ const text = fs.readFileSync(source, 'utf-8');
233
+ const table = source.endsWith('.jsonl') || source.endsWith('.ndjson')
234
+ ? parseJSONL(text)
235
+ : parseCSV(text, opts);
236
+
237
+ for (let i = 0; i < table._rows.length; i += batch) {
238
+ yield new Table(table._rows.slice(i, i + batch), table._columns);
239
+ }
240
+ }
@@ -1,5 +1,13 @@
1
1
  // Fine-grained reactivity system for Tova (signals-based)
2
2
 
3
+ const __DEV__ = typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production';
4
+
5
+ // ─── DevTools hooks (zero-cost when disabled) ────────────
6
+ let __devtools_hooks = null;
7
+ export function __enableDevTools(hooks) {
8
+ __devtools_hooks = hooks;
9
+ }
10
+
3
11
  let currentEffect = null;
4
12
  const effectStack = [];
5
13
 
@@ -106,9 +114,18 @@ function trackDep(subscriber, subscriberSet) {
106
114
 
107
115
  // ─── Signals ─────────────────────────────────────────────
108
116
 
109
- export function createSignal(initialValue) {
117
+ export function createSignal(initialValue, name) {
110
118
  let value = initialValue;
111
119
  const subscribers = new Set();
120
+ let signalId = null;
121
+
122
+ if (__devtools_hooks) {
123
+ signalId = __devtools_hooks.onSignalCreate(
124
+ () => value,
125
+ (v) => setter(v),
126
+ name,
127
+ );
128
+ }
112
129
 
113
130
  function getter() {
114
131
  if (currentEffect) {
@@ -122,7 +139,11 @@ export function createSignal(initialValue) {
122
139
  newValue = newValue(value);
123
140
  }
124
141
  if (value !== newValue) {
142
+ const oldValue = value;
125
143
  value = newValue;
144
+ if (__devtools_hooks && signalId != null) {
145
+ __devtools_hooks.onSignalUpdate(signalId, oldValue, newValue);
146
+ }
126
147
  for (const sub of [...subscribers]) {
127
148
  if (sub._isComputed) {
128
149
  sub(); // propagate dirty flags synchronously through computed graph
@@ -168,6 +189,7 @@ export function createEffect(fn) {
168
189
 
169
190
  effectStack.push(effect);
170
191
  currentEffect = effect;
192
+ const startTime = __devtools_hooks && typeof performance !== 'undefined' ? performance.now() : 0;
171
193
  try {
172
194
  const result = fn();
173
195
  // If effect returns a function, use as cleanup
@@ -180,6 +202,10 @@ export function createEffect(fn) {
180
202
  currentErrorHandler(e);
181
203
  }
182
204
  } finally {
205
+ if (__devtools_hooks) {
206
+ const duration = typeof performance !== 'undefined' ? performance.now() - startTime : 0;
207
+ __devtools_hooks.onEffectRun(effect, duration);
208
+ }
183
209
  effectStack.pop();
184
210
  currentEffect = effectStack[effectStack.length - 1] || null;
185
211
  effect._running = false;
@@ -193,6 +219,10 @@ export function createEffect(fn) {
193
219
  effect._cleanups = [];
194
220
  effect._owner = currentOwner;
195
221
 
222
+ if (__devtools_hooks) {
223
+ __devtools_hooks.onEffectCreate(effect);
224
+ }
225
+
196
226
  if (currentOwner && !currentOwner._disposed) {
197
227
  currentOwner._children.push(effect);
198
228
  }
@@ -357,58 +387,124 @@ export function createRef(initialValue) {
357
387
 
358
388
  // ─── Error Boundaries ────────────────────────────────────
359
389
 
390
+ // Stack-based error handler for correct nested boundary propagation
391
+ const errorHandlerStack = [];
360
392
  let currentErrorHandler = null;
361
393
 
362
- export function createErrorBoundary() {
394
+ function pushErrorHandler(handler) {
395
+ errorHandlerStack.push(currentErrorHandler);
396
+ currentErrorHandler = handler;
397
+ }
398
+
399
+ function popErrorHandler() {
400
+ currentErrorHandler = errorHandlerStack.pop() || null;
401
+ }
402
+
403
+ // Component name tracking for stack traces
404
+ const componentNameStack = [];
405
+
406
+ export function pushComponentName(name) {
407
+ componentNameStack.push(name);
408
+ }
409
+
410
+ export function popComponentName() {
411
+ componentNameStack.pop();
412
+ }
413
+
414
+ function buildComponentStack() {
415
+ return [...componentNameStack].reverse();
416
+ }
417
+
418
+ export function createErrorBoundary(options = {}) {
419
+ const { onError, onReset } = options;
363
420
  const [error, setError] = createSignal(null);
364
421
 
365
422
  function run(fn) {
366
- const prev = currentErrorHandler;
367
- currentErrorHandler = (e) => setError(e);
423
+ pushErrorHandler((e) => {
424
+ const stack = buildComponentStack();
425
+ if (e && typeof e === 'object') e.__tovaComponentStack = stack;
426
+ setError(e);
427
+ if (onError) onError({ error: e, componentStack: stack });
428
+ });
368
429
  try {
369
430
  return fn();
370
431
  } catch (e) {
432
+ const stack = buildComponentStack();
433
+ if (e && typeof e === 'object') e.__tovaComponentStack = stack;
371
434
  setError(e);
435
+ if (onError) onError({ error: e, componentStack: stack });
372
436
  return null;
373
437
  } finally {
374
- currentErrorHandler = prev;
438
+ popErrorHandler();
375
439
  }
376
440
  }
377
441
 
378
442
  function reset() {
379
443
  setError(null);
444
+ if (onReset) onReset();
380
445
  }
381
446
 
382
447
  return { error, run, reset };
383
448
  }
384
449
 
385
- export function ErrorBoundary({ fallback, children }) {
450
+ export function ErrorBoundary({ fallback, children, onError, onReset, retry = 0 }) {
386
451
  const [error, setError] = createSignal(null);
452
+ const [retryCount, setRetryCount] = createSignal(0);
453
+
454
+ function handleError(e) {
455
+ const stack = buildComponentStack();
456
+ if (e && typeof e === 'object') e.__tovaComponentStack = stack;
457
+ if (retryCount() < retry) {
458
+ setRetryCount(c => c + 1);
459
+ setError(null); // clear to re-trigger render
460
+ return;
461
+ }
462
+ setError(e);
463
+ if (onError) onError({ error: e, componentStack: stack });
464
+ }
387
465
 
388
- const prev = currentErrorHandler;
389
- currentErrorHandler = (e) => setError(e);
466
+ pushErrorHandler(handleError);
390
467
 
391
468
  // Return a reactive wrapper that switches between children and fallback
392
- // The __tova_dynamic marker tells the renderer to create an effect for this node
393
469
  const childContent = children && children.length === 1 ? children[0] : tova_fragment(children || []);
394
470
 
395
- currentErrorHandler = prev;
471
+ popErrorHandler();
396
472
 
397
- return {
473
+ const vnode = {
398
474
  __tova: true,
399
475
  tag: '__dynamic',
400
476
  props: {},
401
477
  children: [],
478
+ _fallback: fallback,
479
+ _componentName: 'ErrorBoundary',
402
480
  compute: () => {
403
481
  const err = error();
404
482
  if (err) {
405
- return typeof fallback === 'function'
406
- ? fallback({ error: err, reset: () => setError(null) })
407
- : fallback;
483
+ // Render fallback if fallback itself throws, propagate to parent boundary
484
+ try {
485
+ return typeof fallback === 'function'
486
+ ? fallback({
487
+ error: err,
488
+ reset: () => {
489
+ setRetryCount(0);
490
+ setError(null);
491
+ if (onReset) onReset();
492
+ },
493
+ })
494
+ : fallback;
495
+ } catch (fallbackError) {
496
+ // Fallback threw — propagate to parent error boundary
497
+ if (currentErrorHandler) {
498
+ currentErrorHandler(fallbackError);
499
+ }
500
+ return null;
501
+ }
408
502
  }
409
503
  return childContent;
410
504
  },
411
505
  };
506
+
507
+ return vnode;
412
508
  }
413
509
 
414
510
  // ─── Dynamic Component ──────────────────────────────────
@@ -807,6 +903,14 @@ export function render(vnode) {
807
903
  const el = document.createElement(vnode.tag);
808
904
  applyReactiveProps(el, vnode.props);
809
905
 
906
+ // Set data-tova-component attribute for DevTools
907
+ if (vnode._componentName) {
908
+ el.setAttribute('data-tova-component', vnode._componentName);
909
+ if (__devtools_hooks && __devtools_hooks.onComponentRender) {
910
+ __devtools_hooks.onComponentRender(vnode._componentName, el, 0);
911
+ }
912
+ }
913
+
810
914
  // Render children
811
915
  for (const child of flattenVNodes(vnode.children)) {
812
916
  el.appendChild(render(child));
@@ -1228,6 +1332,59 @@ function patchSingle(parent, existing, newVNode) {
1228
1332
  // SSR renders flat HTML without markers. Hydration attaches reactivity
1229
1333
  // to existing DOM nodes and inserts markers for dynamic blocks.
1230
1334
 
1335
+ // Dev-mode hydration mismatch detection
1336
+ function checkHydrationMismatch(domNode, vnode) {
1337
+ if (!__DEV__) return;
1338
+ if (!domNode || !vnode || !vnode.__tova) return;
1339
+
1340
+ const props = vnode.props || {};
1341
+
1342
+ // Check className
1343
+ if (props.className !== undefined) {
1344
+ const expected = typeof props.className === 'function' ? props.className() : props.className;
1345
+ const actual = domNode.className || '';
1346
+ if (expected && actual !== expected) {
1347
+ console.warn(`Tova hydration mismatch: <${vnode.tag}> class expected "${expected}" but got "${actual}"`);
1348
+ }
1349
+ }
1350
+
1351
+ // Check attributes
1352
+ for (const [key, value] of Object.entries(props)) {
1353
+ if (key === 'key' || key === 'ref' || key === 'className' || key.startsWith('on')) continue;
1354
+ if (typeof value === 'function') continue; // reactive props — skip static check
1355
+
1356
+ if (domNode.getAttribute) {
1357
+ const attrName = key === 'className' ? 'class' : key;
1358
+ const actual = domNode.getAttribute(attrName);
1359
+ const expected = String(value);
1360
+ if (actual !== null && actual !== expected) {
1361
+ console.warn(`Tova hydration mismatch: <${vnode.tag}> attribute "${key}" expected "${expected}" but got "${actual}"`);
1362
+ }
1363
+ }
1364
+ }
1365
+ }
1366
+
1367
+ // Check if a DOM node is an SSR marker comment (<!--tova-s:ID-->)
1368
+ function isSSRMarker(node) {
1369
+ return node && node.nodeType === 8 && typeof node.data === 'string' && node.data.startsWith('tova-s:');
1370
+ }
1371
+
1372
+ // Find the closing SSR marker and collect content nodes between them
1373
+ function collectSSRMarkerContent(startMarker) {
1374
+ const id = startMarker.data.replace('tova-s:', '');
1375
+ const closingText = `/tova-s:${id}`;
1376
+ const content = [];
1377
+ let cursor = startMarker.nextSibling;
1378
+ while (cursor) {
1379
+ if (cursor.nodeType === 8 && cursor.data === closingText) {
1380
+ return { content, endMarker: cursor };
1381
+ }
1382
+ content.push(cursor);
1383
+ cursor = cursor.nextSibling;
1384
+ }
1385
+ return { content, endMarker: null };
1386
+ }
1387
+
1231
1388
  function hydrateVNode(domNode, vnode) {
1232
1389
  if (!domNode) return null;
1233
1390
  if (vnode === null || vnode === undefined) return domNode;
@@ -1235,6 +1392,14 @@ function hydrateVNode(domNode, vnode) {
1235
1392
  // Function vnode (reactive text, JSXIf, JSXFor)
1236
1393
  if (typeof vnode === 'function') {
1237
1394
  if (domNode.nodeType === 3) {
1395
+ // Dev-mode: warn if text content differs
1396
+ if (__DEV__) {
1397
+ const val = vnode();
1398
+ const expected = val == null ? '' : String(val);
1399
+ if (domNode.textContent !== expected) {
1400
+ console.warn(`Tova hydration mismatch: text expected "${expected}" but got "${domNode.textContent}"`);
1401
+ }
1402
+ }
1238
1403
  // Reactive text: attach effect to existing text node
1239
1404
  domNode.__tovaReactive = true;
1240
1405
  createEffect(() => {
@@ -1249,13 +1414,17 @@ function hydrateVNode(domNode, vnode) {
1249
1414
  const next = domNode.nextSibling;
1250
1415
  const rendered = render(vnode);
1251
1416
  parent.replaceChild(rendered, domNode);
1252
- // rendered is a DocumentFragment — its children are now in parent
1253
- // Find the next unprocessed node
1254
1417
  return next;
1255
1418
  }
1256
1419
 
1257
1420
  // Primitive text — already correct from SSR
1258
1421
  if (typeof vnode === 'string' || typeof vnode === 'number' || typeof vnode === 'boolean') {
1422
+ if (__DEV__ && domNode.nodeType === 3) {
1423
+ const expected = String(vnode);
1424
+ if (domNode.textContent !== expected) {
1425
+ console.warn(`Tova hydration mismatch: text expected "${expected}" but got "${domNode.textContent}"`);
1426
+ }
1427
+ }
1259
1428
  return domNode.nextSibling;
1260
1429
  }
1261
1430
 
@@ -1282,8 +1451,26 @@ function hydrateVNode(domNode, vnode) {
1282
1451
  return cursor;
1283
1452
  }
1284
1453
 
1285
- // Dynamic node — replace SSR content with reactive marker
1454
+ // Dynamic node — SSR marker-aware hydration
1286
1455
  if (vnode.tag === '__dynamic' && typeof vnode.compute === 'function') {
1456
+ // Check if current domNode is an SSR marker (<!--tova-s:ID-->)
1457
+ if (isSSRMarker(domNode)) {
1458
+ const { content, endMarker } = collectSSRMarkerContent(domNode);
1459
+ const parent = domNode.parentNode;
1460
+
1461
+ // Remove SSR markers and content, replace with reactive marker
1462
+ const afterEnd = endMarker ? endMarker.nextSibling : null;
1463
+ for (const node of content) {
1464
+ if (node.parentNode === parent) parent.removeChild(node);
1465
+ }
1466
+ if (endMarker && endMarker.parentNode === parent) parent.removeChild(endMarker);
1467
+
1468
+ const rendered = render(vnode);
1469
+ parent.replaceChild(rendered, domNode);
1470
+ return afterEnd;
1471
+ }
1472
+
1473
+ // No SSR marker — fall back to standard behavior
1287
1474
  const parent = domNode.parentNode;
1288
1475
  const next = domNode.nextSibling;
1289
1476
  const rendered = render(vnode);
@@ -1293,6 +1480,7 @@ function hydrateVNode(domNode, vnode) {
1293
1480
 
1294
1481
  // Element — attach event handlers, reactive props, refs
1295
1482
  if (domNode.nodeType === 1 && domNode.tagName.toLowerCase() === vnode.tag.toLowerCase()) {
1483
+ if (__DEV__) checkHydrationMismatch(domNode, vnode);
1296
1484
  hydrateProps(domNode, vnode.props);
1297
1485
  domNode.__vnode = vnode;
1298
1486
 
@@ -1306,6 +1494,11 @@ function hydrateVNode(domNode, vnode) {
1306
1494
  }
1307
1495
 
1308
1496
  // Tag mismatch — fall back to full render
1497
+ if (__DEV__) {
1498
+ const expectedTag = vnode.tag || '(unknown)';
1499
+ const actualTag = domNode.tagName ? domNode.tagName.toLowerCase() : `nodeType:${domNode.nodeType}`;
1500
+ console.warn(`Tova hydration mismatch: expected <${expectedTag}> but got <${actualTag}>, falling back to full render`);
1501
+ }
1309
1502
  const parent = domNode.parentNode;
1310
1503
  const next = domNode.nextSibling;
1311
1504
  const rendered = render(vnode);
@@ -1343,12 +1536,26 @@ export function hydrate(component, container) {
1343
1536
  return;
1344
1537
  }
1345
1538
 
1346
- return createRoot(() => {
1539
+ const startTime = typeof performance !== 'undefined' ? performance.now() : 0;
1540
+
1541
+ const result = createRoot(() => {
1347
1542
  const vnode = typeof component === 'function' ? component() : component;
1348
1543
  if (container.firstChild) {
1349
1544
  hydrateVNode(container.firstChild, vnode);
1350
1545
  }
1351
1546
  });
1547
+
1548
+ // Dispatch hydration completion event
1549
+ const duration = typeof performance !== 'undefined' ? performance.now() - startTime : 0;
1550
+ if (typeof CustomEvent !== 'undefined' && typeof container.dispatchEvent === 'function') {
1551
+ container.dispatchEvent(new CustomEvent('tova:hydrated', { detail: { duration }, bubbles: true }));
1552
+ }
1553
+
1554
+ if (__devtools_hooks && __devtools_hooks.onHydrate) {
1555
+ __devtools_hooks.onHydrate({ duration });
1556
+ }
1557
+
1558
+ return result;
1352
1559
  }
1353
1560
 
1354
1561
  export function mount(component, container) {
@@ -1357,10 +1564,48 @@ export function mount(component, container) {
1357
1564
  return;
1358
1565
  }
1359
1566
 
1360
- return createRoot((dispose) => {
1567
+ const result = createRoot((dispose) => {
1361
1568
  const vnode = typeof component === 'function' ? component() : component;
1362
1569
  container.innerHTML = '';
1363
1570
  container.appendChild(render(vnode));
1364
1571
  return dispose;
1365
1572
  });
1573
+
1574
+ if (__devtools_hooks && __devtools_hooks.onMount) {
1575
+ __devtools_hooks.onMount();
1576
+ }
1577
+
1578
+ return result;
1579
+ }
1580
+
1581
+ // ─── Progressive Hydration ──────────────────────────────────
1582
+ // Hydrate a component only when it becomes visible in the viewport.
1583
+
1584
+ export function hydrateWhenVisible(component, domNode, options = {}) {
1585
+ if (typeof IntersectionObserver === 'undefined') {
1586
+ // Fallback: hydrate immediately
1587
+ return hydrate(component, domNode);
1588
+ }
1589
+
1590
+ const { rootMargin = '200px' } = options;
1591
+ let hydrated = false;
1592
+
1593
+ const observer = new IntersectionObserver(
1594
+ (entries) => {
1595
+ for (const entry of entries) {
1596
+ if (entry.isIntersecting && !hydrated) {
1597
+ hydrated = true;
1598
+ observer.disconnect();
1599
+ hydrate(component, domNode);
1600
+ }
1601
+ }
1602
+ },
1603
+ { rootMargin },
1604
+ );
1605
+
1606
+ observer.observe(domNode);
1607
+
1608
+ return () => {
1609
+ observer.disconnect();
1610
+ };
1366
1611
  }