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.
- package/LICENSE +1 -1
- package/README.md +2 -0
- package/bin/tova.js +811 -154
- package/package.json +8 -2
- package/src/analyzer/analyzer.js +297 -58
- package/src/analyzer/scope.js +38 -1
- package/src/analyzer/type-registry.js +72 -0
- package/src/analyzer/types.js +478 -0
- package/src/codegen/base-codegen.js +371 -0
- package/src/codegen/client-codegen.js +62 -10
- package/src/codegen/codegen.js +111 -2
- package/src/codegen/server-codegen.js +175 -3
- package/src/config/edit-toml.js +100 -0
- package/src/config/package-json.js +52 -0
- package/src/config/resolve.js +100 -0
- package/src/config/toml.js +209 -0
- package/src/lexer/lexer.js +2 -2
- package/src/lsp/server.js +284 -30
- package/src/parser/ast.js +105 -0
- package/src/parser/parser.js +202 -2
- package/src/runtime/ai.js +305 -0
- package/src/runtime/devtools.js +228 -0
- package/src/runtime/embedded.js +3 -1
- package/src/runtime/io.js +240 -0
- package/src/runtime/reactivity.js +264 -19
- package/src/runtime/ssr.js +196 -24
- package/src/runtime/table.js +522 -0
- package/src/stdlib/collections.js +245 -0
- package/src/stdlib/core.js +87 -0
- package/src/stdlib/datetime.js +88 -0
- package/src/stdlib/encoding.js +35 -0
- package/src/stdlib/functional.js +82 -0
- package/src/stdlib/inline.js +334 -67
- package/src/stdlib/math.js +93 -0
- package/src/stdlib/string.js +95 -0
- package/src/stdlib/url.js +33 -0
- package/src/stdlib/validation.js +29 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
471
|
+
popErrorHandler();
|
|
396
472
|
|
|
397
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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 —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|