tova 0.2.9 → 0.3.0
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/bin/tova.js +3 -3
- package/package.json +1 -1
- package/src/analyzer/analyzer.js +125 -60
- package/src/codegen/base-codegen.js +6 -2
- package/src/codegen/client-codegen.js +17 -4
- package/src/codegen/server-codegen.js +7 -2
- package/src/lexer/lexer.js +1 -1
- package/src/lsp/server.js +43 -23
- package/src/parser/parser.js +14 -5
- package/src/runtime/embedded.js +1 -1
- package/src/runtime/reactivity.js +49 -33
- package/src/stdlib/inline.js +7 -7
- package/src/version.js +1 -1
package/bin/tova.js
CHANGED
|
@@ -79,10 +79,10 @@ async function main() {
|
|
|
79
79
|
await runFile(args.filter(a => a !== '--strict')[1], { strict: isStrict });
|
|
80
80
|
break;
|
|
81
81
|
case 'build':
|
|
82
|
-
buildProject(args.slice(1));
|
|
82
|
+
await buildProject(args.slice(1));
|
|
83
83
|
break;
|
|
84
84
|
case 'dev':
|
|
85
|
-
devServer(args.slice(1));
|
|
85
|
+
await devServer(args.slice(1));
|
|
86
86
|
break;
|
|
87
87
|
case 'repl':
|
|
88
88
|
await startRepl();
|
|
@@ -1302,7 +1302,7 @@ async function migrateUp(args) {
|
|
|
1302
1302
|
await db.exec(sql);
|
|
1303
1303
|
}
|
|
1304
1304
|
const ph = db.driver === 'postgres' ? '$1' : '?';
|
|
1305
|
-
await db.
|
|
1305
|
+
await db.query(`INSERT INTO __migrations (name) VALUES (${ph})`, file);
|
|
1306
1306
|
console.log(` ✓ ${file}`);
|
|
1307
1307
|
}
|
|
1308
1308
|
|
package/package.json
CHANGED
package/src/analyzer/analyzer.js
CHANGED
|
@@ -298,7 +298,9 @@ export class Analyzer {
|
|
|
298
298
|
if (['+', '-', '*', '/', '%', '**'].includes(expr.operator)) {
|
|
299
299
|
const lt = this._inferType(expr.left);
|
|
300
300
|
const rt = this._inferType(expr.right);
|
|
301
|
+
if (!lt && !rt) return null;
|
|
301
302
|
if (lt === 'Float' || rt === 'Float') return 'Float';
|
|
303
|
+
if (lt === 'String' || rt === 'String') return 'String';
|
|
302
304
|
return 'Int';
|
|
303
305
|
}
|
|
304
306
|
if (['==', '!=', '<', '>', '<=', '>='].includes(expr.operator)) return 'Bool';
|
|
@@ -567,8 +569,16 @@ export class Analyzer {
|
|
|
567
569
|
return;
|
|
568
570
|
case 'ObjectLiteral':
|
|
569
571
|
for (const prop of node.properties) {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
+
if (prop.spread) {
|
|
573
|
+
// Spread property: {...expr}
|
|
574
|
+
this.visitExpression(prop.argument);
|
|
575
|
+
} else if (prop.shorthand) {
|
|
576
|
+
// Shorthand: {name} — key IS the variable reference
|
|
577
|
+
this.visitExpression(prop.key);
|
|
578
|
+
} else {
|
|
579
|
+
// Non-shorthand: {key: value} — only visit value, key is a label
|
|
580
|
+
this.visitExpression(prop.value);
|
|
581
|
+
}
|
|
572
582
|
}
|
|
573
583
|
return;
|
|
574
584
|
case 'ListComprehension':
|
|
@@ -712,10 +722,10 @@ export class Analyzer {
|
|
|
712
722
|
} finally {
|
|
713
723
|
this.currentScope = prevScope;
|
|
714
724
|
}
|
|
715
|
-
// Promote shared
|
|
716
|
-
// so server/client blocks can reference them
|
|
725
|
+
// Promote shared types and functions to parent scope
|
|
726
|
+
// so server/client blocks can reference them (but not variables)
|
|
717
727
|
for (const [name, sym] of sharedScope.symbols) {
|
|
718
|
-
if (!prevScope.symbols.has(name)) {
|
|
728
|
+
if (!prevScope.symbols.has(name) && (sym.kind === 'type' || sym.kind === 'function')) {
|
|
719
729
|
prevScope.symbols.set(name, sym);
|
|
720
730
|
}
|
|
721
731
|
}
|
|
@@ -832,7 +842,12 @@ export class Analyzer {
|
|
|
832
842
|
// Push expected return type for return-statement checking
|
|
833
843
|
const expectedReturn = node.returnType ? this._typeAnnotationToString(node.returnType) : null;
|
|
834
844
|
this._functionReturnTypeStack.push(expectedReturn);
|
|
835
|
-
|
|
845
|
+
const prevAsyncDepth = this._asyncDepth;
|
|
846
|
+
if (node.isAsync) {
|
|
847
|
+
this._asyncDepth++;
|
|
848
|
+
} else {
|
|
849
|
+
this._asyncDepth = 0; // Non-async function resets async context
|
|
850
|
+
}
|
|
836
851
|
|
|
837
852
|
try {
|
|
838
853
|
for (const param of node.params) {
|
|
@@ -861,7 +876,7 @@ export class Analyzer {
|
|
|
861
876
|
}
|
|
862
877
|
}
|
|
863
878
|
} finally {
|
|
864
|
-
|
|
879
|
+
this._asyncDepth = prevAsyncDepth;
|
|
865
880
|
this._functionReturnTypeStack.pop();
|
|
866
881
|
this.currentScope = prevScope;
|
|
867
882
|
}
|
|
@@ -1211,10 +1226,13 @@ export class Analyzer {
|
|
|
1211
1226
|
this.error(e.message);
|
|
1212
1227
|
}
|
|
1213
1228
|
}
|
|
1214
|
-
|
|
1215
|
-
|
|
1229
|
+
try {
|
|
1230
|
+
for (const child of node.body) {
|
|
1231
|
+
this.visitNode(child);
|
|
1232
|
+
}
|
|
1233
|
+
} finally {
|
|
1234
|
+
this.currentScope = prevScope;
|
|
1216
1235
|
}
|
|
1217
|
-
this.currentScope = prevScope;
|
|
1218
1236
|
}
|
|
1219
1237
|
|
|
1220
1238
|
visitStoreDeclaration(node) {
|
|
@@ -1231,10 +1249,13 @@ export class Analyzer {
|
|
|
1231
1249
|
|
|
1232
1250
|
const prevScope = this.currentScope;
|
|
1233
1251
|
this.currentScope = this.currentScope.child('block');
|
|
1234
|
-
|
|
1235
|
-
|
|
1252
|
+
try {
|
|
1253
|
+
for (const child of node.body) {
|
|
1254
|
+
this.visitNode(child);
|
|
1255
|
+
}
|
|
1256
|
+
} finally {
|
|
1257
|
+
this.currentScope = prevScope;
|
|
1236
1258
|
}
|
|
1237
|
-
this.currentScope = prevScope;
|
|
1238
1259
|
}
|
|
1239
1260
|
|
|
1240
1261
|
visitRouteDeclaration(node) {
|
|
@@ -1288,8 +1309,11 @@ export class Analyzer {
|
|
|
1288
1309
|
this.error(e.message);
|
|
1289
1310
|
}
|
|
1290
1311
|
}
|
|
1291
|
-
|
|
1292
|
-
|
|
1312
|
+
try {
|
|
1313
|
+
this.visitNode(node.body);
|
|
1314
|
+
} finally {
|
|
1315
|
+
this.currentScope = prevScope;
|
|
1316
|
+
}
|
|
1293
1317
|
}
|
|
1294
1318
|
|
|
1295
1319
|
visitHealthCheckDeclaration(node) {
|
|
@@ -1324,8 +1348,11 @@ export class Analyzer {
|
|
|
1324
1348
|
this.error(e.message);
|
|
1325
1349
|
}
|
|
1326
1350
|
}
|
|
1327
|
-
|
|
1328
|
-
|
|
1351
|
+
try {
|
|
1352
|
+
this.visitNode(node.body);
|
|
1353
|
+
} finally {
|
|
1354
|
+
this.currentScope = prevScope;
|
|
1355
|
+
}
|
|
1329
1356
|
}
|
|
1330
1357
|
|
|
1331
1358
|
visitWebSocketDeclaration(node) {
|
|
@@ -1345,8 +1372,11 @@ export class Analyzer {
|
|
|
1345
1372
|
this.error(e.message);
|
|
1346
1373
|
}
|
|
1347
1374
|
}
|
|
1348
|
-
|
|
1349
|
-
|
|
1375
|
+
try {
|
|
1376
|
+
this.visitNode(handler.body);
|
|
1377
|
+
} finally {
|
|
1378
|
+
this.currentScope = prevScope;
|
|
1379
|
+
}
|
|
1350
1380
|
}
|
|
1351
1381
|
}
|
|
1352
1382
|
|
|
@@ -1418,8 +1448,11 @@ export class Analyzer {
|
|
|
1418
1448
|
this.error(e.message);
|
|
1419
1449
|
}
|
|
1420
1450
|
}
|
|
1421
|
-
|
|
1422
|
-
|
|
1451
|
+
try {
|
|
1452
|
+
this.visitNode(node.body);
|
|
1453
|
+
} finally {
|
|
1454
|
+
this.currentScope = prevScope;
|
|
1455
|
+
}
|
|
1423
1456
|
}
|
|
1424
1457
|
|
|
1425
1458
|
visitSubscribeDeclaration(node) {
|
|
@@ -1437,8 +1470,11 @@ export class Analyzer {
|
|
|
1437
1470
|
this.error(e.message);
|
|
1438
1471
|
}
|
|
1439
1472
|
}
|
|
1440
|
-
|
|
1441
|
-
|
|
1473
|
+
try {
|
|
1474
|
+
this.visitNode(node.body);
|
|
1475
|
+
} finally {
|
|
1476
|
+
this.currentScope = prevScope;
|
|
1477
|
+
}
|
|
1442
1478
|
}
|
|
1443
1479
|
|
|
1444
1480
|
visitEnvDeclaration(node) {
|
|
@@ -1480,8 +1516,11 @@ export class Analyzer {
|
|
|
1480
1516
|
this.error(e.message);
|
|
1481
1517
|
}
|
|
1482
1518
|
}
|
|
1483
|
-
|
|
1484
|
-
|
|
1519
|
+
try {
|
|
1520
|
+
this.visitNode(node.body);
|
|
1521
|
+
} finally {
|
|
1522
|
+
this.currentScope = prevScope;
|
|
1523
|
+
}
|
|
1485
1524
|
}
|
|
1486
1525
|
|
|
1487
1526
|
visitUploadDeclaration(node) {
|
|
@@ -1555,8 +1594,11 @@ export class Analyzer {
|
|
|
1555
1594
|
this.error(e.message);
|
|
1556
1595
|
}
|
|
1557
1596
|
}
|
|
1558
|
-
|
|
1559
|
-
|
|
1597
|
+
try {
|
|
1598
|
+
this.visitNode(node.body);
|
|
1599
|
+
} finally {
|
|
1600
|
+
this.currentScope = prevScope;
|
|
1601
|
+
}
|
|
1560
1602
|
}
|
|
1561
1603
|
|
|
1562
1604
|
visitCacheDeclaration(node) {
|
|
@@ -1579,10 +1621,13 @@ export class Analyzer {
|
|
|
1579
1621
|
for (const p of node.params) {
|
|
1580
1622
|
this.currentScope.define(p.name, { kind: 'param' });
|
|
1581
1623
|
}
|
|
1582
|
-
|
|
1583
|
-
|
|
1624
|
+
try {
|
|
1625
|
+
for (const stmt of node.body.body || []) {
|
|
1626
|
+
this.visitNode(stmt);
|
|
1627
|
+
}
|
|
1628
|
+
} finally {
|
|
1629
|
+
this.currentScope = prevScope;
|
|
1584
1630
|
}
|
|
1585
|
-
this.currentScope = prevScope;
|
|
1586
1631
|
}
|
|
1587
1632
|
|
|
1588
1633
|
visitModelDeclaration(node) {
|
|
@@ -1600,10 +1645,13 @@ export class Analyzer {
|
|
|
1600
1645
|
visitTestBlock(node) {
|
|
1601
1646
|
const prevScope = this.currentScope;
|
|
1602
1647
|
this.currentScope = this.currentScope.child('block');
|
|
1603
|
-
|
|
1604
|
-
|
|
1648
|
+
try {
|
|
1649
|
+
for (const stmt of node.body) {
|
|
1650
|
+
this.visitNode(stmt);
|
|
1651
|
+
}
|
|
1652
|
+
} finally {
|
|
1653
|
+
this.currentScope = prevScope;
|
|
1605
1654
|
}
|
|
1606
|
-
this.currentScope = prevScope;
|
|
1607
1655
|
}
|
|
1608
1656
|
|
|
1609
1657
|
// ─── Expression visitors ──────────────────────────────────
|
|
@@ -1639,7 +1687,12 @@ export class Analyzer {
|
|
|
1639
1687
|
|
|
1640
1688
|
const expectedReturn = node.returnType ? this._typeAnnotationToString(node.returnType) : null;
|
|
1641
1689
|
this._functionReturnTypeStack.push(expectedReturn);
|
|
1642
|
-
|
|
1690
|
+
const prevAsyncDepth = this._asyncDepth;
|
|
1691
|
+
if (node.isAsync) {
|
|
1692
|
+
this._asyncDepth++;
|
|
1693
|
+
} else {
|
|
1694
|
+
this._asyncDepth = 0; // Non-async lambda resets async context
|
|
1695
|
+
}
|
|
1643
1696
|
|
|
1644
1697
|
try {
|
|
1645
1698
|
for (const param of node.params) {
|
|
@@ -1662,7 +1715,7 @@ export class Analyzer {
|
|
|
1662
1715
|
this.visitExpression(node.body);
|
|
1663
1716
|
}
|
|
1664
1717
|
} finally {
|
|
1665
|
-
|
|
1718
|
+
this._asyncDepth = prevAsyncDepth;
|
|
1666
1719
|
this._functionReturnTypeStack.pop();
|
|
1667
1720
|
this.currentScope = prevScope;
|
|
1668
1721
|
}
|
|
@@ -1764,31 +1817,35 @@ export class Analyzer {
|
|
|
1764
1817
|
}
|
|
1765
1818
|
}
|
|
1766
1819
|
|
|
1767
|
-
// Check user-defined types —
|
|
1768
|
-
//
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
_collectTypeVariants(node, allVariants, coveredVariants, matchLoc) {
|
|
1776
|
-
if (node.type === 'TypeDeclaration') {
|
|
1777
|
-
const typeVariants = node.variants.filter(v => v.type === 'TypeVariant').map(v => v.name);
|
|
1778
|
-
// If any of the match arms reference a variant from this type, check all
|
|
1779
|
-
const relevantVariants = typeVariants.filter(v => coveredVariants.has(v));
|
|
1780
|
-
if (relevantVariants.length > 0) {
|
|
1820
|
+
// Check user-defined types — find the single best-matching type whose variants
|
|
1821
|
+
// contain ALL covered variant names (avoids false positives with shared names)
|
|
1822
|
+
const candidates = [];
|
|
1823
|
+
this._collectTypeCandidates(this.ast.body, coveredVariants, candidates);
|
|
1824
|
+
// Only warn if exactly one type contains all covered variants
|
|
1825
|
+
if (candidates.length === 1) {
|
|
1826
|
+
const [typeName, typeVariants] = candidates[0];
|
|
1781
1827
|
for (const v of typeVariants) {
|
|
1782
1828
|
if (!coveredVariants.has(v)) {
|
|
1783
|
-
this.warn(`Non-exhaustive match: missing '${v}' variant from type '${
|
|
1829
|
+
this.warn(`Non-exhaustive match: missing '${v}' variant from type '${typeName}'`, node.loc);
|
|
1784
1830
|
}
|
|
1785
1831
|
}
|
|
1786
1832
|
}
|
|
1787
1833
|
}
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
_collectTypeCandidates(nodes, coveredVariants, candidates) {
|
|
1837
|
+
for (const node of nodes) {
|
|
1838
|
+
if (node.type === 'TypeDeclaration') {
|
|
1839
|
+
const typeVariants = node.variants.filter(v => v.type === 'TypeVariant').map(v => v.name);
|
|
1840
|
+
if (typeVariants.length === 0) continue;
|
|
1841
|
+
// All covered variants must be contained in this type's variants
|
|
1842
|
+
const allCovered = [...coveredVariants].every(v => typeVariants.includes(v));
|
|
1843
|
+
if (allCovered) {
|
|
1844
|
+
candidates.push([node.name, typeVariants]);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
if (node.type === 'SharedBlock' || node.type === 'ServerBlock' || node.type === 'ClientBlock') {
|
|
1848
|
+
this._collectTypeCandidates(node.body, coveredVariants, candidates);
|
|
1792
1849
|
}
|
|
1793
1850
|
}
|
|
1794
1851
|
}
|
|
@@ -1963,7 +2020,8 @@ export class Analyzer {
|
|
|
1963
2020
|
return true;
|
|
1964
2021
|
case 'BlockStatement':
|
|
1965
2022
|
if (node.body.length === 0) return false;
|
|
1966
|
-
|
|
2023
|
+
// Any statement that definitely returns makes the block definitely return
|
|
2024
|
+
return node.body.some(stmt => this._definitelyReturns(stmt));
|
|
1967
2025
|
case 'IfStatement':
|
|
1968
2026
|
if (!node.elseBody) return false;
|
|
1969
2027
|
const consequentReturns = this._definitelyReturns(node.consequent);
|
|
@@ -1971,8 +2029,9 @@ export class Analyzer {
|
|
|
1971
2029
|
const allAlternatesReturn = (node.alternates || []).every(alt => this._definitelyReturns(alt.body));
|
|
1972
2030
|
return consequentReturns && elseReturns && allAlternatesReturn;
|
|
1973
2031
|
case 'GuardStatement':
|
|
1974
|
-
// Guard
|
|
1975
|
-
return
|
|
2032
|
+
// Guard only handles the failure case — when condition is true, execution falls through
|
|
2033
|
+
// A guard alone never guarantees return on ALL paths
|
|
2034
|
+
return false;
|
|
1976
2035
|
case 'MatchExpression': {
|
|
1977
2036
|
const hasWildcard = node.arms.some(arm =>
|
|
1978
2037
|
arm.pattern.type === 'WildcardPattern' ||
|
|
@@ -1983,15 +2042,15 @@ export class Analyzer {
|
|
|
1983
2042
|
}
|
|
1984
2043
|
case 'TryCatchStatement': {
|
|
1985
2044
|
const tryReturns = node.tryBody.length > 0 &&
|
|
1986
|
-
|
|
2045
|
+
node.tryBody.some(s => this._definitelyReturns(s));
|
|
1987
2046
|
const catchReturns = !node.catchBody || (node.catchBody.length > 0 &&
|
|
1988
|
-
|
|
2047
|
+
node.catchBody.some(s => this._definitelyReturns(s)));
|
|
1989
2048
|
return tryReturns && catchReturns;
|
|
1990
2049
|
}
|
|
1991
2050
|
case 'ExpressionStatement':
|
|
1992
2051
|
return this._definitelyReturns(node.expression);
|
|
1993
2052
|
case 'CallExpression':
|
|
1994
|
-
return
|
|
2053
|
+
return false;
|
|
1995
2054
|
default:
|
|
1996
2055
|
return false;
|
|
1997
2056
|
}
|
|
@@ -2059,6 +2118,12 @@ export class Analyzer {
|
|
|
2059
2118
|
this.strictError(`Type mismatch: '++' expects String on right side, but got ${rightType}`, node.loc);
|
|
2060
2119
|
}
|
|
2061
2120
|
} else if (['-', '*', '/', '%', '**'].includes(op)) {
|
|
2121
|
+
// String literal * Int is valid (string repeat) — skip warning for that case
|
|
2122
|
+
if (op === '*') {
|
|
2123
|
+
const leftIsStr = node.left.type === 'StringLiteral' || node.left.type === 'TemplateLiteral';
|
|
2124
|
+
const rightIsStr = node.right.type === 'StringLiteral' || node.right.type === 'TemplateLiteral';
|
|
2125
|
+
if (leftIsStr || rightIsStr) return;
|
|
2126
|
+
}
|
|
2062
2127
|
// Arithmetic: both sides must be numeric
|
|
2063
2128
|
const numerics = new Set(['Int', 'Float']);
|
|
2064
2129
|
if (leftType && !numerics.has(leftType) && leftType !== 'Any') {
|
|
@@ -679,11 +679,15 @@ export class BaseCodegen {
|
|
|
679
679
|
const right = this.genExpression(node.right);
|
|
680
680
|
const op = node.operator;
|
|
681
681
|
|
|
682
|
-
// String multiply: "ha" * 3 => "ha".repeat(3)
|
|
682
|
+
// String multiply: "ha" * 3 => "ha".repeat(3), also x * 3 when x is string
|
|
683
683
|
if (op === '*' &&
|
|
684
684
|
(node.left.type === 'StringLiteral' || node.left.type === 'TemplateLiteral')) {
|
|
685
685
|
return `${left}.repeat(${right})`;
|
|
686
686
|
}
|
|
687
|
+
if (op === '*' &&
|
|
688
|
+
(node.right.type === 'StringLiteral' || node.right.type === 'TemplateLiteral')) {
|
|
689
|
+
return `${right}.repeat(${left})`;
|
|
690
|
+
}
|
|
687
691
|
|
|
688
692
|
// Tova ?? is NaN-safe: catches null, undefined, AND NaN
|
|
689
693
|
if (op === '??') {
|
|
@@ -1027,7 +1031,7 @@ export class BaseCodegen {
|
|
|
1027
1031
|
if (node.type === 'TemplateLiteral') {
|
|
1028
1032
|
// Template literal with column references
|
|
1029
1033
|
const parts = node.parts.map(p => {
|
|
1030
|
-
if (p.type === 'text') return p.value;
|
|
1034
|
+
if (p.type === 'text') return p.value.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
|
1031
1035
|
return `\${${this._genColumnBody(p.value)}}`;
|
|
1032
1036
|
});
|
|
1033
1037
|
return '`' + parts.join('') + '`';
|
|
@@ -27,6 +27,7 @@ export class ClientCodegen extends BaseCodegen {
|
|
|
27
27
|
}
|
|
28
28
|
if (node.type === 'IfExpression') {
|
|
29
29
|
return this._containsRPC(node.condition) || this._containsRPC(node.consequent) ||
|
|
30
|
+
(node.alternates && node.alternates.some(a => this._containsRPC(a.condition) || this._containsRPC(a.body))) ||
|
|
30
31
|
this._containsRPC(node.elseBody);
|
|
31
32
|
}
|
|
32
33
|
if (node.type === 'ForStatement') return this._containsRPC(node.iterable) || this._containsRPC(node.body);
|
|
@@ -45,14 +46,15 @@ export class ClientCodegen extends BaseCodegen {
|
|
|
45
46
|
return this._containsRPC(node.subject) || node.arms.some(a => this._containsRPC(a.body));
|
|
46
47
|
}
|
|
47
48
|
if (node.type === 'TryCatchStatement') {
|
|
48
|
-
return
|
|
49
|
-
this._containsRPC(
|
|
49
|
+
return (node.tryBody && node.tryBody.some(s => this._containsRPC(s))) ||
|
|
50
|
+
(node.catchBody && node.catchBody.some(s => this._containsRPC(s))) ||
|
|
51
|
+
(node.finallyBody && node.finallyBody.some(s => this._containsRPC(s)));
|
|
50
52
|
}
|
|
51
53
|
if (node.type === 'PipeExpression') {
|
|
52
54
|
return this._containsRPC(node.left) || this._containsRPC(node.right);
|
|
53
55
|
}
|
|
54
56
|
if (node.type === 'GuardStatement') {
|
|
55
|
-
return this._containsRPC(node.condition) || this._containsRPC(node.
|
|
57
|
+
return this._containsRPC(node.condition) || this._containsRPC(node.elseBody);
|
|
56
58
|
}
|
|
57
59
|
if (node.type === 'LetDestructure') return this._containsRPC(node.value);
|
|
58
60
|
if (node.type === 'ArrayLiteral') return node.elements.some(e => this._containsRPC(e));
|
|
@@ -61,7 +63,7 @@ export class ClientCodegen extends BaseCodegen {
|
|
|
61
63
|
if (node.type === 'AwaitExpression') return this._containsRPC(node.argument);
|
|
62
64
|
if (node.type === 'PropagateExpression') return this._containsRPC(node.expression);
|
|
63
65
|
if (node.type === 'UnaryExpression') return this._containsRPC(node.operand);
|
|
64
|
-
if (node.type === 'TemplateLiteral') return node.parts.some(p => p.type === 'expr' && this._containsRPC(p.
|
|
66
|
+
if (node.type === 'TemplateLiteral') return node.parts.some(p => p.type === 'expr' && this._containsRPC(p.value));
|
|
65
67
|
if (node.type === 'ChainedComparison') return node.operands.some(o => this._containsRPC(o));
|
|
66
68
|
if (node.type === 'RangeExpression') return this._containsRPC(node.start) || this._containsRPC(node.end);
|
|
67
69
|
if (node.type === 'SliceExpression') return this._containsRPC(node.object) || this._containsRPC(node.start) || this._containsRPC(node.end) || this._containsRPC(node.step);
|
|
@@ -602,6 +604,7 @@ export class ClientCodegen extends BaseCodegen {
|
|
|
602
604
|
if (node.type === 'ObjectLiteral') return node.properties.some(p => this._exprReadsSignal(p.value));
|
|
603
605
|
if (node.type === 'IfExpression') {
|
|
604
606
|
return this._exprReadsSignal(node.condition) || this._exprReadsSignal(node.consequent) ||
|
|
607
|
+
(node.alternates && node.alternates.some(a => this._exprReadsSignal(a.condition) || this._exprReadsSignal(a.body))) ||
|
|
605
608
|
this._exprReadsSignal(node.elseBody);
|
|
606
609
|
}
|
|
607
610
|
if (node.type === 'MatchExpression') {
|
|
@@ -876,6 +879,16 @@ export class ClientCodegen extends BaseCodegen {
|
|
|
876
879
|
return `() => ${result}`;
|
|
877
880
|
}
|
|
878
881
|
|
|
882
|
+
// Override to add await for piped RPC calls
|
|
883
|
+
genPipeExpression(node) {
|
|
884
|
+
const result = super.genPipeExpression(node);
|
|
885
|
+
// If the pipe target is an RPC call and we're in async context, wrap with await
|
|
886
|
+
if (this._asyncContext && this._containsRPC(node.right)) {
|
|
887
|
+
return `await ${result}`;
|
|
888
|
+
}
|
|
889
|
+
return result;
|
|
890
|
+
}
|
|
891
|
+
|
|
879
892
|
// Override function declaration to make async if it contains server.* calls
|
|
880
893
|
genFunctionDeclaration(node) {
|
|
881
894
|
const hasRPC = this._containsRPC(node.body);
|
|
@@ -474,6 +474,7 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
474
474
|
lines.push(this._getAiRuntime());
|
|
475
475
|
lines.push('');
|
|
476
476
|
|
|
477
|
+
let hasDefaultAi = false;
|
|
477
478
|
for (const aiConf of aiConfigs) {
|
|
478
479
|
const configParts = [];
|
|
479
480
|
for (const [key, valueNode] of Object.entries(aiConf.config)) {
|
|
@@ -487,8 +488,13 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
487
488
|
} else {
|
|
488
489
|
// Default provider: ai { ... } → const ai = __createAI({...})
|
|
489
490
|
lines.push(`const ai = __createAI(${configStr});`);
|
|
491
|
+
hasDefaultAi = true;
|
|
490
492
|
}
|
|
491
493
|
}
|
|
494
|
+
// If no default ai config, create a default for one-off calls
|
|
495
|
+
if (!hasDefaultAi) {
|
|
496
|
+
lines.push('const ai = __createAI({});');
|
|
497
|
+
}
|
|
492
498
|
lines.push('');
|
|
493
499
|
}
|
|
494
500
|
|
|
@@ -2672,7 +2678,6 @@ function __createAI(config) {
|
|
|
2672
2678
|
classify(text, categories, opts) { return __aiRequest('classify', [text, categories, opts || {}], opts); },
|
|
2673
2679
|
};
|
|
2674
2680
|
}
|
|
2675
|
-
|
|
2676
|
-
const ai = typeof ai === 'undefined' ? __createAI({}) : ai;`;
|
|
2681
|
+
`;
|
|
2677
2682
|
}
|
|
2678
2683
|
}
|
package/src/lexer/lexer.js
CHANGED
|
@@ -86,7 +86,7 @@ export class Lexer {
|
|
|
86
86
|
const prev = this.tokens.length > 1 ? this.tokens[this.tokens.length - 2] : null;
|
|
87
87
|
if (!prev) return true;
|
|
88
88
|
const valueTypes = [TokenType.IDENTIFIER, TokenType.NUMBER, TokenType.STRING,
|
|
89
|
-
TokenType.STRING_TEMPLATE, TokenType.RPAREN, TokenType.RBRACKET,
|
|
89
|
+
TokenType.STRING_TEMPLATE, TokenType.RPAREN, TokenType.RBRACKET, TokenType.RBRACE,
|
|
90
90
|
TokenType.TRUE, TokenType.FALSE, TokenType.NIL];
|
|
91
91
|
return !valueTypes.includes(prev.type);
|
|
92
92
|
}
|
package/src/lsp/server.js
CHANGED
|
@@ -13,7 +13,7 @@ class TovaLanguageServer {
|
|
|
13
13
|
static MAX_CACHE_SIZE = 100; // max cached diagnostics entries
|
|
14
14
|
|
|
15
15
|
constructor() {
|
|
16
|
-
this._buffer =
|
|
16
|
+
this._buffer = Buffer.alloc(0);
|
|
17
17
|
this._documents = new Map(); // uri -> { text, version }
|
|
18
18
|
this._diagnosticsCache = new Map(); // uri -> { ast, analyzer, errors, typeRegistry }
|
|
19
19
|
this._initialized = false;
|
|
@@ -22,7 +22,7 @@ class TovaLanguageServer {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
start() {
|
|
25
|
-
|
|
25
|
+
// Do NOT set encoding — use raw Buffers for correct byte-based Content-Length (LSP protocol)
|
|
26
26
|
process.stdin.on('data', (chunk) => this._onData(chunk));
|
|
27
27
|
process.stdin.on('end', () => process.exit(0));
|
|
28
28
|
|
|
@@ -46,12 +46,13 @@ class TovaLanguageServer {
|
|
|
46
46
|
// ─── JSON-RPC Transport ────────────────────────────────────
|
|
47
47
|
|
|
48
48
|
_onData(chunk) {
|
|
49
|
-
this._buffer
|
|
49
|
+
this._buffer = Buffer.concat([this._buffer, typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk]);
|
|
50
50
|
while (true) {
|
|
51
|
-
const
|
|
51
|
+
const sep = Buffer.from('\r\n\r\n');
|
|
52
|
+
const headerEnd = this._buffer.indexOf(sep);
|
|
52
53
|
if (headerEnd === -1) break;
|
|
53
54
|
|
|
54
|
-
const header = this._buffer.slice(0, headerEnd);
|
|
55
|
+
const header = this._buffer.slice(0, headerEnd).toString('utf8');
|
|
55
56
|
const match = header.match(/Content-Length:\s*(\d+)/i);
|
|
56
57
|
if (!match) {
|
|
57
58
|
this._buffer = this._buffer.slice(headerEnd + 4);
|
|
@@ -62,7 +63,7 @@ class TovaLanguageServer {
|
|
|
62
63
|
const start = headerEnd + 4;
|
|
63
64
|
if (this._buffer.length < start + contentLength) break;
|
|
64
65
|
|
|
65
|
-
const body = this._buffer.slice(start, start + contentLength);
|
|
66
|
+
const body = this._buffer.slice(start, start + contentLength).toString('utf8');
|
|
66
67
|
this._buffer = this._buffer.slice(start + contentLength);
|
|
67
68
|
|
|
68
69
|
try {
|
|
@@ -305,7 +306,7 @@ class TovaLanguageServer {
|
|
|
305
306
|
diagnostics.push({
|
|
306
307
|
range: {
|
|
307
308
|
start: { line: (e.line || 1) - 1, character: (e.column || 1) - 1 },
|
|
308
|
-
end: { line: (e.line || 1) - 1, character: (e.column || 1) + 10 },
|
|
309
|
+
end: { line: (e.line || 1) - 1, character: (e.column || 1) - 1 + 10 },
|
|
309
310
|
},
|
|
310
311
|
severity: 1,
|
|
311
312
|
source: 'tova',
|
|
@@ -714,11 +715,32 @@ class TovaLanguageServer {
|
|
|
714
715
|
const line = doc.text.split('\n')[position.line] || '';
|
|
715
716
|
const before = line.slice(0, position.character);
|
|
716
717
|
|
|
717
|
-
//
|
|
718
|
-
|
|
719
|
-
|
|
718
|
+
// Walk backwards to find the immediately enclosing function call (handles nesting)
|
|
719
|
+
let depth = 0;
|
|
720
|
+
let parenPos = -1;
|
|
721
|
+
for (let i = before.length - 1; i >= 0; i--) {
|
|
722
|
+
if (before[i] === ')') depth++;
|
|
723
|
+
else if (before[i] === '(') {
|
|
724
|
+
if (depth === 0) { parenPos = i; break; }
|
|
725
|
+
depth--;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
if (parenPos === -1) return this._respond(msg.id, null);
|
|
729
|
+
|
|
730
|
+
const funcMatch = before.slice(0, parenPos).match(/(\w+)\s*$/);
|
|
731
|
+
if (!funcMatch) return this._respond(msg.id, null);
|
|
732
|
+
|
|
733
|
+
const funcName = funcMatch[1];
|
|
720
734
|
|
|
721
|
-
|
|
735
|
+
// Count commas at depth 0 after the enclosing paren (ignores nested call commas)
|
|
736
|
+
const afterParen = before.slice(parenPos + 1);
|
|
737
|
+
let activeParam = 0;
|
|
738
|
+
let parenDepth = 0;
|
|
739
|
+
for (const ch of afterParen) {
|
|
740
|
+
if (ch === '(') parenDepth++;
|
|
741
|
+
else if (ch === ')') parenDepth--;
|
|
742
|
+
else if (ch === ',' && parenDepth === 0) activeParam++;
|
|
743
|
+
}
|
|
722
744
|
|
|
723
745
|
// Built-in signatures
|
|
724
746
|
const signatures = {
|
|
@@ -737,10 +759,6 @@ class TovaLanguageServer {
|
|
|
737
759
|
|
|
738
760
|
const sig = signatures[funcName];
|
|
739
761
|
if (sig) {
|
|
740
|
-
// Count commas to determine active parameter
|
|
741
|
-
const afterParen = before.slice(before.lastIndexOf('(') + 1);
|
|
742
|
-
const activeParam = (afterParen.match(/,/g) || []).length;
|
|
743
|
-
|
|
744
762
|
return this._respond(msg.id, {
|
|
745
763
|
signatures: [{
|
|
746
764
|
label: sig.label,
|
|
@@ -755,17 +773,14 @@ class TovaLanguageServer {
|
|
|
755
773
|
const cached = this._diagnosticsCache.get(textDocument.uri);
|
|
756
774
|
if (cached?.analyzer) {
|
|
757
775
|
const symbol = this._findSymbolInScopes(cached.analyzer, funcName);
|
|
758
|
-
if (symbol?.
|
|
759
|
-
const afterParen = before.slice(before.lastIndexOf('(') + 1);
|
|
760
|
-
const activeParam = (afterParen.match(/,/g) || []).length;
|
|
761
|
-
|
|
776
|
+
if (symbol?._params) {
|
|
762
777
|
return this._respond(msg.id, {
|
|
763
778
|
signatures: [{
|
|
764
|
-
label: `${funcName}(${symbol.
|
|
765
|
-
parameters: symbol.
|
|
779
|
+
label: `${funcName}(${symbol._params.join(', ')})`,
|
|
780
|
+
parameters: symbol._params.map(p => ({ label: p })),
|
|
766
781
|
}],
|
|
767
782
|
activeSignature: 0,
|
|
768
|
-
activeParameter: Math.min(activeParam, symbol.
|
|
783
|
+
activeParameter: Math.max(0, Math.min(activeParam, symbol._params.length - 1)),
|
|
769
784
|
});
|
|
770
785
|
}
|
|
771
786
|
}
|
|
@@ -901,7 +916,12 @@ class TovaLanguageServer {
|
|
|
901
916
|
|
|
902
917
|
_uriToPath(uri) {
|
|
903
918
|
if (uri.startsWith('file://')) {
|
|
904
|
-
|
|
919
|
+
let path = decodeURIComponent(uri.slice(7));
|
|
920
|
+
// On Windows, file:///C:/path becomes /C:/path — strip leading slash
|
|
921
|
+
if (/^\/[a-zA-Z]:/.test(path)) {
|
|
922
|
+
path = path.slice(1);
|
|
923
|
+
}
|
|
924
|
+
return path;
|
|
905
925
|
}
|
|
906
926
|
return uri;
|
|
907
927
|
}
|
package/src/parser/parser.js
CHANGED
|
@@ -2110,7 +2110,8 @@ export class Parser {
|
|
|
2110
2110
|
parsePipe() {
|
|
2111
2111
|
let left = this.parseNullCoalesce();
|
|
2112
2112
|
while (this.match(TokenType.PIPE)) {
|
|
2113
|
-
const
|
|
2113
|
+
const opTok = this.tokens[this.pos - 1];
|
|
2114
|
+
const l = { line: opTok.line, column: opTok.column, file: this.filename };
|
|
2114
2115
|
// Method pipe: |> .method(args) — parse as MemberExpression with empty Identifier
|
|
2115
2116
|
if (this.check(TokenType.DOT)) {
|
|
2116
2117
|
this.advance(); // consume .
|
|
@@ -2134,7 +2135,8 @@ export class Parser {
|
|
|
2134
2135
|
parseNullCoalesce() {
|
|
2135
2136
|
let left = this.parseOr();
|
|
2136
2137
|
while (this.match(TokenType.QUESTION_QUESTION)) {
|
|
2137
|
-
const
|
|
2138
|
+
const opTok = this.tokens[this.pos - 1];
|
|
2139
|
+
const l = { line: opTok.line, column: opTok.column, file: this.filename };
|
|
2138
2140
|
const right = this.parseOr();
|
|
2139
2141
|
left = new AST.BinaryExpression('??', left, right, l);
|
|
2140
2142
|
}
|
|
@@ -2144,7 +2146,8 @@ export class Parser {
|
|
|
2144
2146
|
parseOr() {
|
|
2145
2147
|
let left = this.parseAnd();
|
|
2146
2148
|
while (this.match(TokenType.OR_OR) || this.match(TokenType.OR)) {
|
|
2147
|
-
const
|
|
2149
|
+
const opTok = this.tokens[this.pos - 1];
|
|
2150
|
+
const l = { line: opTok.line, column: opTok.column, file: this.filename };
|
|
2148
2151
|
const right = this.parseAnd();
|
|
2149
2152
|
left = new AST.LogicalExpression('or', left, right, l);
|
|
2150
2153
|
}
|
|
@@ -2154,7 +2157,8 @@ export class Parser {
|
|
|
2154
2157
|
parseAnd() {
|
|
2155
2158
|
let left = this.parseNot();
|
|
2156
2159
|
while (this.match(TokenType.AND_AND) || this.match(TokenType.AND)) {
|
|
2157
|
-
const
|
|
2160
|
+
const opTok = this.tokens[this.pos - 1];
|
|
2161
|
+
const l = { line: opTok.line, column: opTok.column, file: this.filename };
|
|
2158
2162
|
const right = this.parseNot();
|
|
2159
2163
|
left = new AST.LogicalExpression('and', left, right, l);
|
|
2160
2164
|
}
|
|
@@ -2163,7 +2167,8 @@ export class Parser {
|
|
|
2163
2167
|
|
|
2164
2168
|
parseNot() {
|
|
2165
2169
|
if (this.match(TokenType.NOT) || this.match(TokenType.BANG)) {
|
|
2166
|
-
const
|
|
2170
|
+
const opTok = this.tokens[this.pos - 1];
|
|
2171
|
+
const l = { line: opTok.line, column: opTok.column, file: this.filename };
|
|
2167
2172
|
const operand = this.parseNot();
|
|
2168
2173
|
return new AST.UnaryExpression('not', operand, true, l);
|
|
2169
2174
|
}
|
|
@@ -2349,6 +2354,10 @@ export class Parser {
|
|
|
2349
2354
|
}
|
|
2350
2355
|
|
|
2351
2356
|
if (this.check(TokenType.LPAREN)) {
|
|
2357
|
+
// Don't treat ( as call if it's on a new line (avoids ambiguity with grouped expressions)
|
|
2358
|
+
const prevLine = this.pos > 0 ? this.tokens[this.pos - 1].line : 0;
|
|
2359
|
+
const curLine = this.current().line;
|
|
2360
|
+
if (curLine > prevLine) break;
|
|
2352
2361
|
expr = this.parseCallExpression(expr);
|
|
2353
2362
|
continue;
|
|
2354
2363
|
}
|
package/src/runtime/embedded.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Auto-generated by scripts/embed-runtime.js — do not edit
|
|
2
2
|
|
|
3
|
-
export const REACTIVITY_SOURCE = "// Fine-grained reactivity system for Tova (signals-based)\n\nconst __DEV__ = typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production';\n\n// ─── DevTools hooks (zero-cost when disabled) ────────────\nlet __devtools_hooks = null;\nexport function __enableDevTools(hooks) {\n __devtools_hooks = hooks;\n}\n\nlet currentEffect = null;\nconst effectStack = [];\n\n// ─── Ownership System ─────────────────────────────────────\nlet currentOwner = null;\nconst ownerStack = [];\n\n// ─── Batching ────────────────────────────────────────────\n// Default: synchronous flush after each setter (backward compatible).\n// Inside batch(): effects are deferred and flushed once when batch ends.\n// This means setA(1); setB(2) causes 2 runs by default, but\n// batch(() => { setA(1); setB(2); }) causes only 1 run.\n\nlet pendingEffects = new Set();\nlet batchDepth = 0;\nlet flushing = false;\n\nfunction flush() {\n if (flushing) return; // prevent re-entrant flush\n flushing = true;\n let iterations = 0;\n try {\n while (pendingEffects.size > 0) {\n if (++iterations > 100) {\n console.error('Tova: Possible infinite loop in reactive updates (>100 flush iterations). Aborting.');\n pendingEffects.clear();\n break;\n }\n const toRun = [...pendingEffects];\n pendingEffects.clear();\n for (const effect of toRun) {\n if (!effect._disposed) {\n effect();\n }\n }\n }\n } finally {\n flushing = false;\n }\n}\n\nexport function batch(fn) {\n batchDepth++;\n try {\n fn();\n } finally {\n batchDepth--;\n if (batchDepth === 0) {\n flush();\n }\n }\n}\n\n// ─── Ownership Root ──────────────────────────────────────\n\nexport function createRoot(fn) {\n const root = {\n _children: [],\n _disposed: false,\n _cleanups: [],\n _contexts: null,\n _owner: currentOwner,\n dispose() {\n if (root._disposed) return;\n root._disposed = true;\n // Dispose children in reverse order\n for (let i = root._children.length - 1; i >= 0; i--) {\n const child = root._children[i];\n if (typeof child.dispose === 'function') child.dispose();\n }\n root._children.length = 0;\n // Run cleanups in reverse order\n for (let i = root._cleanups.length - 1; i >= 0; i--) {\n try { root._cleanups[i](); } catch (e) { console.error('Tova: root cleanup error:', e); }\n }\n root._cleanups.length = 0;\n }\n };\n ownerStack.push(currentOwner);\n currentOwner = root;\n try {\n return fn(root.dispose.bind(root));\n } finally {\n currentOwner = ownerStack.pop();\n }\n}\n\n// ─── Dependency Cleanup ──────────────────────────────────\n\nfunction cleanupDeps(subscriber) {\n if (subscriber._deps) {\n for (const depSet of subscriber._deps) {\n depSet.delete(subscriber);\n }\n subscriber._deps.clear();\n }\n}\n\nfunction trackDep(subscriber, subscriberSet) {\n subscriberSet.add(subscriber);\n if (!subscriber._deps) subscriber._deps = new Set();\n subscriber._deps.add(subscriberSet);\n}\n\n// ─── Signals ─────────────────────────────────────────────\n\nexport function createSignal(initialValue, name) {\n let value = initialValue;\n const subscribers = new Set();\n let signalId = null;\n\n if (__devtools_hooks) {\n signalId = __devtools_hooks.onSignalCreate(\n () => value,\n (v) => setter(v),\n name,\n );\n }\n\n function getter() {\n if (currentEffect) {\n trackDep(currentEffect, subscribers);\n }\n return value;\n }\n\n function setter(newValue) {\n if (typeof newValue === 'function') {\n newValue = newValue(value);\n }\n if (value !== newValue) {\n const oldValue = value;\n value = newValue;\n if (__devtools_hooks && signalId != null) {\n __devtools_hooks.onSignalUpdate(signalId, oldValue, newValue);\n }\n for (const sub of [...subscribers]) {\n if (sub._isComputed) {\n sub(); // propagate dirty flags synchronously through computed graph\n } else {\n pendingEffects.add(sub);\n }\n }\n if (batchDepth === 0) {\n flush();\n }\n }\n }\n\n return [getter, setter];\n}\n\n// ─── Effects ─────────────────────────────────────────────\n\nfunction runCleanups(effect) {\n if (effect._cleanup) {\n try { effect._cleanup(); } catch (e) { console.error('Tova: cleanup error:', e); }\n effect._cleanup = null;\n }\n if (effect._cleanups && effect._cleanups.length > 0) {\n for (const cb of effect._cleanups) {\n try { cb(); } catch (e) { console.error('Tova: cleanup error:', e); }\n }\n effect._cleanups = [];\n }\n}\n\nexport function createEffect(fn) {\n function effect() {\n if (effect._running) return;\n if (effect._disposed) return;\n effect._running = true;\n\n // Run cleanups from previous execution\n runCleanups(effect);\n\n // Remove from all previous dependency subscriber sets\n cleanupDeps(effect);\n\n effectStack.push(effect);\n currentEffect = effect;\n const startTime = __devtools_hooks && typeof performance !== 'undefined' ? performance.now() : 0;\n try {\n const result = fn();\n // If effect returns a function, use as cleanup\n if (typeof result === 'function') {\n effect._cleanup = result;\n }\n } catch (e) {\n console.error('Tova: Error in effect:', e);\n if (currentErrorHandler) {\n currentErrorHandler(e);\n }\n } finally {\n if (__devtools_hooks) {\n const duration = typeof performance !== 'undefined' ? performance.now() - startTime : 0;\n __devtools_hooks.onEffectRun(effect, duration);\n }\n effectStack.pop();\n currentEffect = effectStack[effectStack.length - 1] || null;\n effect._running = false;\n }\n }\n\n effect._deps = new Set();\n effect._running = false;\n effect._disposed = false;\n effect._cleanup = null;\n effect._cleanups = [];\n effect._owner = currentOwner;\n\n if (__devtools_hooks) {\n __devtools_hooks.onEffectCreate(effect);\n }\n\n if (currentOwner && !currentOwner._disposed) {\n currentOwner._children.push(effect);\n }\n\n effect.dispose = function () {\n effect._disposed = true;\n runCleanups(effect);\n cleanupDeps(effect);\n pendingEffects.delete(effect);\n // Remove from owner's children\n if (effect._owner) {\n const idx = effect._owner._children.indexOf(effect);\n if (idx >= 0) effect._owner._children.splice(idx, 1);\n }\n };\n\n // Run immediately (synchronous first run)\n effect();\n return effect;\n}\n\n// ─── Computed (lazy/pull-based for glitch-free reads) ────\n\nexport function createComputed(fn) {\n let value;\n let dirty = true;\n const subscribers = new Set();\n\n // notify is called synchronously when a source signal changes.\n // It marks the computed dirty and propagates to downstream subscribers.\n function notify() {\n if (!dirty) {\n dirty = true;\n for (const sub of [...subscribers]) {\n if (sub._isComputed) {\n sub(); // cascade dirty flags synchronously\n } else {\n pendingEffects.add(sub);\n }\n }\n }\n }\n\n notify._deps = new Set();\n notify._disposed = false;\n notify._isComputed = true;\n notify._owner = currentOwner;\n\n if (currentOwner && !currentOwner._disposed) {\n currentOwner._children.push(notify);\n }\n\n notify.dispose = function () {\n notify._disposed = true;\n cleanupDeps(notify);\n if (notify._owner) {\n const idx = notify._owner._children.indexOf(notify);\n if (idx >= 0) notify._owner._children.splice(idx, 1);\n }\n };\n\n function recompute() {\n cleanupDeps(notify);\n\n effectStack.push(notify);\n currentEffect = notify;\n try {\n value = fn();\n dirty = false;\n } finally {\n effectStack.pop();\n currentEffect = effectStack[effectStack.length - 1] || null;\n }\n }\n\n // Initial computation\n recompute();\n\n function getter() {\n if (currentEffect) {\n trackDep(currentEffect, subscribers);\n }\n if (dirty) {\n recompute();\n }\n return value;\n }\n\n return getter;\n}\n\n// ─── Lifecycle Hooks ─────────────────────────────────────\n\nexport function onMount(fn) {\n const owner = currentOwner;\n queueMicrotask(() => {\n const result = fn();\n if (typeof result === 'function' && owner && !owner._disposed) {\n owner._cleanups.push(result);\n }\n });\n}\n\nexport function onUnmount(fn) {\n if (currentOwner && !currentOwner._disposed) {\n currentOwner._cleanups.push(fn);\n }\n}\n\nexport function onCleanup(fn) {\n if (currentEffect) {\n if (!currentEffect._cleanups) currentEffect._cleanups = [];\n currentEffect._cleanups.push(fn);\n }\n}\n\n// ─── Untrack ─────────────────────────────────────────────\n// Run a function without tracking any signal reads (opt out of reactivity)\n\nexport function untrack(fn) {\n const prev = currentEffect;\n currentEffect = null;\n try {\n return fn();\n } finally {\n currentEffect = prev;\n }\n}\n\n// ─── Watch ───────────────────────────────────────────────\n// Watch a reactive expression, calling callback with (newValue, oldValue)\n// Returns a dispose function to stop watching.\n\nexport function watch(getter, callback, options = {}) {\n let oldValue = undefined;\n let initialized = false;\n\n const effect = createEffect(() => {\n const newValue = getter();\n if (initialized) {\n callback(newValue, oldValue);\n } else if (options.immediate) {\n callback(newValue, undefined);\n }\n oldValue = newValue;\n initialized = true;\n });\n\n return effect.dispose ? effect.dispose.bind(effect) : () => {\n effect._disposed = true;\n runCleanups(effect);\n cleanupDeps(effect);\n pendingEffects.delete(effect);\n };\n}\n\n// ─── Refs ────────────────────────────────────────────────\n\nexport function createRef(initialValue) {\n return { current: initialValue !== undefined ? initialValue : null };\n}\n\n// ─── Error Boundaries ────────────────────────────────────\n\n// Stack-based error handler for correct nested boundary propagation\nconst errorHandlerStack = [];\nlet currentErrorHandler = null;\n\nfunction pushErrorHandler(handler) {\n errorHandlerStack.push(currentErrorHandler);\n currentErrorHandler = handler;\n}\n\nfunction popErrorHandler() {\n currentErrorHandler = errorHandlerStack.pop() || null;\n}\n\n// Component name tracking for stack traces\nconst componentNameStack = [];\n\nexport function pushComponentName(name) {\n componentNameStack.push(name);\n}\n\nexport function popComponentName() {\n componentNameStack.pop();\n}\n\nfunction buildComponentStack() {\n return [...componentNameStack].reverse();\n}\n\nexport function createErrorBoundary(options = {}) {\n const { onError, onReset } = options;\n const [error, setError] = createSignal(null);\n\n function run(fn) {\n pushErrorHandler((e) => {\n const stack = buildComponentStack();\n if (e && typeof e === 'object') e.__tovaComponentStack = stack;\n setError(e);\n if (onError) onError({ error: e, componentStack: stack });\n });\n try {\n return fn();\n } catch (e) {\n const stack = buildComponentStack();\n if (e && typeof e === 'object') e.__tovaComponentStack = stack;\n setError(e);\n if (onError) onError({ error: e, componentStack: stack });\n return null;\n } finally {\n popErrorHandler();\n }\n }\n\n function reset() {\n setError(null);\n if (onReset) onReset();\n }\n\n return { error, run, reset };\n}\n\nexport function ErrorBoundary({ fallback, children, onError, onReset, retry = 0 }) {\n const [error, setError] = createSignal(null);\n const [retryCount, setRetryCount] = createSignal(0);\n\n function handleError(e) {\n const stack = buildComponentStack();\n if (e && typeof e === 'object') e.__tovaComponentStack = stack;\n if (retryCount() < retry) {\n setRetryCount(c => c + 1);\n setError(null); // clear to re-trigger render\n return;\n }\n setError(e);\n if (onError) onError({ error: e, componentStack: stack });\n }\n\n pushErrorHandler(handleError);\n\n // Return a reactive wrapper that switches between children and fallback\n const childContent = children && children.length === 1 ? children[0] : tova_fragment(children || []);\n\n popErrorHandler();\n\n const vnode = {\n __tova: true,\n tag: '__dynamic',\n props: {},\n children: [],\n _fallback: fallback,\n _componentName: 'ErrorBoundary',\n compute: () => {\n const err = error();\n if (err) {\n // Render fallback — if fallback itself throws, propagate to parent boundary\n try {\n return typeof fallback === 'function'\n ? fallback({\n error: err,\n reset: () => {\n setRetryCount(0);\n setError(null);\n if (onReset) onReset();\n },\n })\n : fallback;\n } catch (fallbackError) {\n // Fallback threw — propagate to parent error boundary\n if (currentErrorHandler) {\n currentErrorHandler(fallbackError);\n }\n return null;\n }\n }\n return childContent;\n },\n };\n\n return vnode;\n}\n\n// ─── Dynamic Component ──────────────────────────────────\n// Renders a component dynamically based on a reactive signal.\n// Usage: Dynamic({ component: mySignal, ...props })\n\nexport function Dynamic({ component, ...rest }) {\n return {\n __tova: true,\n tag: '__dynamic',\n props: {},\n children: [],\n compute: () => {\n const comp = typeof component === 'function' && !component.__tova ? component() : component;\n if (!comp) return null;\n if (typeof comp === 'function') {\n return comp(rest);\n }\n return comp;\n },\n };\n}\n\n// ─── Portal ─────────────────────────────────────────────\n// Renders children into a different DOM target.\n// Usage: Portal({ target: \"#modal-root\", children })\n\nexport function Portal({ target, children }) {\n return {\n __tova: true,\n tag: '__portal',\n props: { target },\n children: children || [],\n };\n}\n\n// ─── Lazy ───────────────────────────────────────────────\n// Async component loading with optional fallback.\n// Usage: const LazyComp = lazy(() => import('./HeavyComponent.js'))\n\nexport function lazy(loader) {\n let resolved = null;\n let promise = null;\n\n return function LazyWrapper(props) {\n if (resolved) {\n return resolved(props);\n }\n\n const [comp, setComp] = createSignal(null);\n const [err, setErr] = createSignal(null);\n\n if (!promise) {\n promise = loader()\n .then(mod => {\n resolved = mod.default || mod;\n setComp(() => resolved);\n })\n .catch(e => setErr(e));\n }\n\n return {\n __tova: true,\n tag: '__dynamic',\n props: {},\n children: [],\n compute: () => {\n const e = err();\n if (e) return tova_el('span', { className: 'tova-error' }, [String(e)]);\n const c = comp();\n if (c) return c(props);\n // Fallback while loading\n return props && props.fallback ? props.fallback : null;\n },\n };\n };\n}\n\n// ─── Context (Provide/Inject) ────────────────────────────\n// Tree-based: values are stored on the ownership tree, inject walks up.\n\nexport function createContext(defaultValue) {\n const id = Symbol('context');\n return { _id: id, _default: defaultValue };\n}\n\nexport function provide(context, value) {\n const owner = currentOwner;\n if (owner) {\n if (!owner._contexts) owner._contexts = new Map();\n owner._contexts.set(context._id, value);\n }\n}\n\nexport function inject(context) {\n let owner = currentOwner;\n while (owner) {\n if (owner._contexts && owner._contexts.has(context._id)) {\n return owner._contexts.get(context._id);\n }\n owner = owner._owner;\n }\n return context._default;\n}\n\n// ─── DOM Rendering ────────────────────────────────────────\n\n// Inject scoped CSS into the page (idempotent — only injects once per id)\nconst __tovaInjectedStyles = new Set();\nexport function tova_inject_css(id, css) {\n if (__tovaInjectedStyles.has(id)) return;\n __tovaInjectedStyles.add(id);\n const style = document.createElement('style');\n style.setAttribute('data-tova-style', id);\n style.textContent = css;\n document.head.appendChild(style);\n}\n\nexport function tova_el(tag, props = {}, children = []) {\n return { __tova: true, tag, props, children };\n}\n\nexport function tova_fragment(children) {\n return { __tova: true, tag: '__fragment', props: {}, children };\n}\n\n// Inject a key prop into a vnode for keyed reconciliation\nexport function tova_keyed(key, vnode) {\n if (vnode && vnode.__tova) {\n vnode.props = { ...vnode.props, key };\n }\n return vnode;\n}\n\n// Flatten nested arrays and vnodes into a flat list of vnodes\nfunction flattenVNodes(children) {\n const result = [];\n for (const child of children) {\n if (child === null || child === undefined) {\n continue;\n } else if (Array.isArray(child)) {\n result.push(...flattenVNodes(child));\n } else {\n result.push(child);\n }\n }\n return result;\n}\n\n// ─── Marker-based DOM helpers ─────────────────────────────\n// Instead of wrapping dynamic blocks/fragments in <span style=\"display:contents\">,\n// we use comment node markers. A marker's __tovaNodes tracks its content nodes.\n// Content nodes have __tovaOwner pointing to their owning marker.\n\n// Recursively dispose ownership roots attached to a DOM subtree\nfunction disposeNode(node) {\n if (!node) return;\n if (node.__tovaRoot) {\n node.__tovaRoot();\n node.__tovaRoot = null;\n }\n // If this is a marker, dispose and remove its content nodes\n if (node.__tovaNodes) {\n for (const cn of node.__tovaNodes) {\n disposeNode(cn);\n if (cn.parentNode) cn.parentNode.removeChild(cn);\n }\n node.__tovaNodes = [];\n }\n if (node.childNodes) {\n for (const child of Array.from(node.childNodes)) {\n disposeNode(child);\n }\n }\n}\n\n// Check if a node is transitively owned by a marker (walks __tovaOwner chain)\nfunction isOwnedBy(node, marker) {\n let owner = node.__tovaOwner;\n while (owner) {\n if (owner === marker) return true;\n owner = owner.__tovaOwner;\n }\n return false;\n}\n\n// Get logical children of a parent element (skips marker content nodes)\nfunction getLogicalChildren(parent) {\n const logical = [];\n for (let i = 0; i < parent.childNodes.length; i++) {\n const node = parent.childNodes[i];\n if (!node.__tovaOwner) {\n logical.push(node);\n }\n }\n return logical;\n}\n\n// Find the first DOM sibling after all of a marker's content\nfunction nextSiblingAfterMarker(marker) {\n if (!marker.__tovaNodes || marker.__tovaNodes.length === 0) {\n return marker.nextSibling;\n }\n let last = marker.__tovaNodes[marker.__tovaNodes.length - 1];\n // If last content is itself a marker, recurse to find physical end\n while (last && last.__tovaNodes && last.__tovaNodes.length > 0) {\n last = last.__tovaNodes[last.__tovaNodes.length - 1];\n }\n return last ? last.nextSibling : marker.nextSibling;\n}\n\n// Remove a logical node (marker + its content, or a regular node) from the DOM\nfunction removeLogicalNode(parent, node) {\n disposeNode(node);\n if (node.parentNode === parent) parent.removeChild(node);\n}\n\n// Insert rendered result (could be single node or DocumentFragment) before ref,\n// setting __tovaOwner on top-level inserted nodes. Returns array of inserted nodes.\nfunction insertRendered(parent, rendered, ref, owner) {\n if (rendered.nodeType === 11) {\n const nodes = Array.from(rendered.childNodes);\n for (const n of nodes) {\n if (!n.__tovaOwner) n.__tovaOwner = owner;\n }\n parent.insertBefore(rendered, ref);\n return nodes;\n }\n if (!rendered.__tovaOwner) rendered.__tovaOwner = owner;\n parent.insertBefore(rendered, ref);\n return [rendered];\n}\n\n// Clear a marker's content from the DOM and reset __tovaNodes\nfunction clearMarkerContent(marker) {\n for (const node of marker.__tovaNodes) {\n disposeNode(node);\n if (node.parentNode) node.parentNode.removeChild(node);\n }\n marker.__tovaNodes = [];\n}\n\n// ─── Render ───────────────────────────────────────────────\n\n// Create real DOM nodes from a vnode (with fine-grained reactive bindings).\n// Returns a single DOM node for elements/text, or a DocumentFragment for\n// markers (dynamic blocks, fragments) containing [marker, ...content].\nexport function render(vnode) {\n if (vnode === null || vnode === undefined) {\n return document.createTextNode('');\n }\n\n // Reactive dynamic block (JSXIf, JSXFor, reactive text, etc.)\n if (typeof vnode === 'function') {\n const marker = document.createComment('');\n marker.__tovaDynamic = true;\n marker.__tovaNodes = [];\n\n const frag = document.createDocumentFragment();\n frag.appendChild(marker);\n\n createEffect(() => {\n const val = vnode();\n const parent = marker.parentNode;\n const ref = nextSiblingAfterMarker(marker);\n\n // Array: keyed or positional reconciliation within marker range\n if (Array.isArray(val)) {\n const flat = flattenVNodes(val);\n const hasKeys = flat.some(c => getKey(c) != null);\n if (hasKeys) {\n patchKeyedInMarker(marker, flat);\n } else {\n patchPositionalInMarker(marker, flat);\n }\n return;\n }\n\n // Text: optimize single text node update in place\n if (val == null || typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {\n const text = val == null ? '' : String(val);\n if (marker.__tovaNodes.length === 1 && marker.__tovaNodes[0].nodeType === 3) {\n if (marker.__tovaNodes[0].textContent !== text) {\n marker.__tovaNodes[0].textContent = text;\n }\n return;\n }\n clearMarkerContent(marker);\n const textNode = document.createTextNode(text);\n textNode.__tovaOwner = marker;\n parent.insertBefore(textNode, ref);\n marker.__tovaNodes = [textNode];\n return;\n }\n\n // Vnode or other: clear and re-render\n clearMarkerContent(marker);\n if (val && val.__tova) {\n const rendered = render(val);\n marker.__tovaNodes = insertRendered(parent, rendered, ref, marker);\n } else {\n const textNode = document.createTextNode(String(val));\n textNode.__tovaOwner = marker;\n parent.insertBefore(textNode, ref);\n marker.__tovaNodes = [textNode];\n }\n });\n\n return frag;\n }\n\n if (typeof vnode === 'string' || typeof vnode === 'number' || typeof vnode === 'boolean') {\n return document.createTextNode(String(vnode));\n }\n\n if (Array.isArray(vnode)) {\n const fragment = document.createDocumentFragment();\n for (const child of vnode) {\n fragment.appendChild(render(child));\n }\n return fragment;\n }\n\n if (!vnode.__tova) {\n return document.createTextNode(String(vnode));\n }\n\n // Fragment — marker + children (no wrapper element)\n if (vnode.tag === '__fragment') {\n const marker = document.createComment('');\n marker.__tovaFragment = true;\n marker.__tovaNodes = [];\n marker.__vnode = vnode;\n\n const frag = document.createDocumentFragment();\n frag.appendChild(marker);\n\n for (const child of flattenVNodes(vnode.children)) {\n const rendered = render(child);\n const inserted = insertRendered(frag, rendered, null, marker);\n marker.__tovaNodes.push(...inserted);\n }\n\n return frag;\n }\n\n // Dynamic reactive node (ErrorBoundary, Dynamic component, etc.)\n if (vnode.tag === '__dynamic' && typeof vnode.compute === 'function') {\n const marker = document.createComment('');\n marker.__tovaDynamic = true;\n marker.__tovaNodes = [];\n\n const frag = document.createDocumentFragment();\n frag.appendChild(marker);\n\n let prevDispose = null;\n createEffect(() => {\n const inner = vnode.compute();\n const parent = marker.parentNode;\n const ref = nextSiblingAfterMarker(marker);\n\n if (prevDispose) {\n prevDispose();\n prevDispose = null;\n }\n clearMarkerContent(marker);\n\n createRoot((dispose) => {\n prevDispose = dispose;\n const rendered = render(inner);\n marker.__tovaNodes = insertRendered(parent, rendered, ref, marker);\n });\n });\n\n return frag;\n }\n\n // Portal — render children into a different DOM target\n if (vnode.tag === '__portal') {\n const placeholder = document.createComment('portal');\n const targetSelector = vnode.props.target;\n queueMicrotask(() => {\n const targetEl = typeof targetSelector === 'string'\n ? document.querySelector(targetSelector)\n : targetSelector;\n if (targetEl) {\n for (const child of flattenVNodes(vnode.children)) {\n targetEl.appendChild(render(child));\n }\n }\n });\n return placeholder;\n }\n\n // Element\n const el = document.createElement(vnode.tag);\n applyReactiveProps(el, vnode.props);\n\n // Set data-tova-component attribute for DevTools\n if (vnode._componentName) {\n el.setAttribute('data-tova-component', vnode._componentName);\n if (__devtools_hooks && __devtools_hooks.onComponentRender) {\n __devtools_hooks.onComponentRender(vnode._componentName, el, 0);\n }\n }\n\n // Render children\n for (const child of flattenVNodes(vnode.children)) {\n el.appendChild(render(child));\n }\n\n // Store vnode reference for patching\n el.__vnode = vnode;\n\n return el;\n}\n\n// Apply reactive props — function-valued props get their own effect\nfunction applyReactiveProps(el, props) {\n for (const [key, value] of Object.entries(props)) {\n if (key === 'ref') {\n if (typeof value === 'object' && value !== null && 'current' in value) {\n value.current = el;\n } else if (typeof value === 'function') {\n value(el);\n }\n } else if (key.startsWith('on')) {\n const eventName = key.slice(2).toLowerCase();\n el.addEventListener(eventName, value);\n if (!el.__handlers) el.__handlers = {};\n el.__handlers[eventName] = value;\n } else if (key === 'key') {\n // Skip\n } else if (typeof value === 'function' && !key.startsWith('on')) {\n // Reactive prop — create effect for fine-grained updates\n createEffect(() => {\n const val = value();\n applyPropValue(el, key, val);\n });\n } else {\n applyPropValue(el, key, value);\n }\n }\n}\n\nfunction applyPropValue(el, key, val) {\n if (key === 'className') {\n if (el.className !== val) el.className = val || '';\n } else if (key === 'innerHTML' || key === 'dangerouslySetInnerHTML') {\n const html = typeof val === 'object' && val !== null ? val.__html || '' : val || '';\n if (el.innerHTML !== html) el.innerHTML = html;\n } else if (key === 'value') {\n if (el !== document.activeElement && el.value !== val) {\n el.value = val;\n }\n } else if (key === 'checked') {\n el.checked = !!val;\n } else if (key === 'disabled' || key === 'readOnly' || key === 'hidden') {\n el[key] = !!val;\n } else if (key === 'style' && typeof val === 'object') {\n Object.assign(el.style, val);\n } else {\n const s = val == null ? '' : String(val);\n if (el.getAttribute(key) !== s) {\n el.setAttribute(key, s);\n }\n }\n}\n\n// Apply/update props on a DOM element (used by patcher for full-tree mode)\nfunction applyProps(el, newProps, oldProps) {\n // Remove old props that are no longer present\n for (const key of Object.keys(oldProps)) {\n if (!(key in newProps)) {\n if (key.startsWith('on')) {\n const eventName = key.slice(2).toLowerCase();\n if (el.__handlers && el.__handlers[eventName]) {\n el.removeEventListener(eventName, el.__handlers[eventName]);\n delete el.__handlers[eventName];\n }\n } else if (key === 'className') {\n el.className = '';\n } else if (key === 'style') {\n el.removeAttribute('style');\n } else {\n el.removeAttribute(key);\n }\n }\n }\n\n // Apply new props\n for (const [key, value] of Object.entries(newProps)) {\n if (key === 'className') {\n const val = typeof value === 'function' ? value() : value;\n if (el.className !== val) el.className = val;\n } else if (key === 'ref') {\n if (typeof value === 'object' && value !== null && 'current' in value) {\n value.current = el;\n } else if (typeof value === 'function') {\n value(el);\n }\n } else if (key.startsWith('on')) {\n const eventName = key.slice(2).toLowerCase();\n const oldHandler = el.__handlers && el.__handlers[eventName];\n if (oldHandler !== value) {\n if (oldHandler) el.removeEventListener(eventName, oldHandler);\n el.addEventListener(eventName, value);\n if (!el.__handlers) el.__handlers = {};\n el.__handlers[eventName] = value;\n }\n } else if (key === 'style' && typeof value === 'object') {\n Object.assign(el.style, value);\n } else if (key === 'key') {\n // Skip\n } else if (key === 'value') {\n const val = typeof value === 'function' ? value() : value;\n if (el !== document.activeElement && el.value !== val) {\n el.value = val;\n }\n } else if (key === 'checked') {\n el.checked = !!value;\n } else {\n const val = typeof value === 'function' ? value() : value;\n if (el.getAttribute(key) !== String(val)) {\n el.setAttribute(key, val);\n }\n }\n }\n}\n\n// ─── Keyed Reconciliation ────────────────────────────────\n\nfunction getKey(vnode) {\n if (vnode && vnode.__tova && vnode.props) return vnode.props.key;\n return undefined;\n}\n\nfunction getNodeKey(node) {\n if (node && node.__vnode && node.__vnode.props) return node.__vnode.props.key;\n return undefined;\n}\n\n// Keyed reconciliation within a marker's content range\nfunction patchKeyedInMarker(marker, newVNodes) {\n const parent = marker.parentNode;\n const oldNodes = [...marker.__tovaNodes];\n const oldKeyMap = new Map();\n\n for (const node of oldNodes) {\n const key = getNodeKey(node);\n if (key != null) oldKeyMap.set(key, node);\n }\n\n const newNodes = [];\n const usedOld = new Set();\n\n for (const newChild of newVNodes) {\n const key = getKey(newChild);\n\n if (key != null && oldKeyMap.has(key)) {\n const oldNode = oldKeyMap.get(key);\n usedOld.add(oldNode);\n\n if (oldNode.nodeType === 1 && newChild.__tova &&\n oldNode.tagName.toLowerCase() === newChild.tag.toLowerCase()) {\n const oldVNode = oldNode.__vnode || { props: {}, children: [] };\n applyProps(oldNode, newChild.props, oldVNode.props);\n patchChildrenOfElement(oldNode, flattenVNodes(newChild.children));\n oldNode.__vnode = newChild;\n newNodes.push(oldNode);\n } else {\n const node = render(newChild);\n // render may return Fragment — collect nodes\n if (node.nodeType === 11) {\n const nodes = Array.from(node.childNodes);\n for (const n of nodes) { if (!n.__tovaOwner) n.__tovaOwner = marker; }\n parent.insertBefore(node, nextSiblingAfterMarker(marker));\n newNodes.push(...nodes);\n } else {\n if (!node.__tovaOwner) node.__tovaOwner = marker;\n newNodes.push(node);\n }\n }\n } else {\n const node = render(newChild);\n if (node.nodeType === 11) {\n const nodes = Array.from(node.childNodes);\n for (const n of nodes) { if (!n.__tovaOwner) n.__tovaOwner = marker; }\n parent.insertBefore(node, nextSiblingAfterMarker(marker));\n newNodes.push(...nodes);\n } else {\n if (!node.__tovaOwner) node.__tovaOwner = marker;\n newNodes.push(node);\n }\n }\n }\n\n // Remove unused old nodes\n for (const node of oldNodes) {\n if (!usedOld.has(node)) {\n disposeNode(node);\n if (node.parentNode === parent) parent.removeChild(node);\n }\n }\n\n // Arrange in correct order after marker using cursor approach\n let cursor = marker.nextSibling;\n for (const node of newNodes) {\n if (node === cursor) {\n cursor = node.nextSibling;\n } else {\n parent.insertBefore(node, cursor);\n }\n }\n\n marker.__tovaNodes = newNodes;\n}\n\n// Positional reconciliation within a marker's content range\nfunction patchPositionalInMarker(marker, newChildren) {\n const parent = marker.parentNode;\n const oldNodes = [...marker.__tovaNodes];\n const oldCount = oldNodes.length;\n const newCount = newChildren.length;\n\n // Patch in place\n const patchCount = Math.min(oldCount, newCount);\n for (let i = 0; i < patchCount; i++) {\n patchSingle(parent, oldNodes[i], newChildren[i]);\n }\n\n // Append new children\n const ref = nextSiblingAfterMarker(marker);\n for (let i = oldCount; i < newCount; i++) {\n const rendered = render(newChildren[i]);\n const inserted = insertRendered(parent, rendered, ref, marker);\n oldNodes.push(...inserted);\n }\n\n // Remove excess children\n for (let i = newCount; i < oldCount; i++) {\n disposeNode(oldNodes[i]);\n if (oldNodes[i].parentNode === parent) parent.removeChild(oldNodes[i]);\n }\n\n marker.__tovaNodes = oldNodes.slice(0, Math.max(newCount, oldCount > newCount ? newCount : oldNodes.length));\n // Simplify: rebuild __tovaNodes from what should remain\n if (newCount <= oldCount) {\n marker.__tovaNodes = oldNodes.slice(0, newCount);\n }\n}\n\n// Keyed reconciliation for children of an element (not marker-based)\nfunction patchKeyedChildren(parent, newVNodes) {\n const logical = getLogicalChildren(parent);\n const oldKeyMap = new Map();\n\n for (const node of logical) {\n const key = getNodeKey(node);\n if (key != null) oldKeyMap.set(key, node);\n }\n\n const newNodes = [];\n const usedOld = new Set();\n\n for (const newChild of newVNodes) {\n const key = getKey(newChild);\n\n if (key != null && oldKeyMap.has(key)) {\n const oldNode = oldKeyMap.get(key);\n usedOld.add(oldNode);\n\n if (oldNode.nodeType === 1 && newChild.__tova &&\n oldNode.tagName.toLowerCase() === newChild.tag.toLowerCase()) {\n const oldVNode = oldNode.__vnode || { props: {}, children: [] };\n applyProps(oldNode, newChild.props, oldVNode.props);\n patchChildrenOfElement(oldNode, flattenVNodes(newChild.children));\n oldNode.__vnode = newChild;\n newNodes.push(oldNode);\n } else {\n newNodes.push(render(newChild));\n }\n } else {\n newNodes.push(render(newChild));\n }\n }\n\n // Remove unused old logical nodes\n for (const node of logical) {\n if (!usedOld.has(node) && node.parentNode === parent) {\n removeLogicalNode(parent, node);\n }\n }\n\n // Arrange in correct order\n for (let i = 0; i < newNodes.length; i++) {\n const expected = newNodes[i];\n const logicalNow = getLogicalChildren(parent);\n const current = logicalNow[i];\n if (current !== expected) {\n parent.insertBefore(expected, current || null);\n }\n }\n}\n\n// Positional reconciliation for children of an element\nfunction patchPositionalChildren(parent, newChildren) {\n const logical = getLogicalChildren(parent);\n const oldCount = logical.length;\n const newCount = newChildren.length;\n\n for (let i = 0; i < Math.min(oldCount, newCount); i++) {\n patchSingle(parent, logical[i], newChildren[i]);\n }\n\n for (let i = oldCount; i < newCount; i++) {\n parent.appendChild(render(newChildren[i]));\n }\n\n // Remove excess logical children\n const currentLogical = getLogicalChildren(parent);\n while (currentLogical.length > newCount) {\n const node = currentLogical.pop();\n removeLogicalNode(parent, node);\n }\n}\n\n// Patch children of a regular element\nfunction patchChildrenOfElement(el, newChildren) {\n const hasKeys = newChildren.some(c => getKey(c) != null);\n if (hasKeys) {\n patchKeyedChildren(el, newChildren);\n } else {\n patchPositionalChildren(el, newChildren);\n }\n}\n\n// Patch a single logical node in place\nfunction patchSingle(parent, existing, newVNode) {\n if (!existing) {\n parent.appendChild(render(newVNode));\n return;\n }\n\n if (newVNode === null || newVNode === undefined) {\n removeLogicalNode(parent, existing);\n return;\n }\n\n // Function vnode — replace with new dynamic block\n if (typeof newVNode === 'function') {\n const rendered = render(newVNode);\n if (existing.__tovaNodes) {\n // Existing is a marker — clear its content and replace\n clearMarkerContent(existing);\n parent.replaceChild(rendered, existing);\n } else {\n disposeNode(existing);\n parent.replaceChild(rendered, existing);\n }\n return;\n }\n\n // Text\n if (typeof newVNode === 'string' || typeof newVNode === 'number' || typeof newVNode === 'boolean') {\n const text = String(newVNode);\n if (existing.nodeType === 3) {\n if (existing.textContent !== text) existing.textContent = text;\n } else {\n removeLogicalNode(parent, existing);\n parent.insertBefore(document.createTextNode(text), null);\n }\n return;\n }\n\n if (!newVNode.__tova) {\n const text = String(newVNode);\n if (existing.nodeType === 3) {\n if (existing.textContent !== text) existing.textContent = text;\n } else {\n removeLogicalNode(parent, existing);\n parent.insertBefore(document.createTextNode(text), null);\n }\n return;\n }\n\n // Fragment — patch marker content\n if (newVNode.tag === '__fragment') {\n if (existing.__tovaFragment) {\n // Patch children within the marker range\n const oldNodes = [...existing.__tovaNodes];\n const newChildren = flattenVNodes(newVNode.children);\n // Simple approach: clear and re-render fragment content\n clearMarkerContent(existing);\n const ref = nextSiblingAfterMarker(existing);\n for (const child of newChildren) {\n const rendered = render(child);\n const inserted = insertRendered(parent, rendered, ref, existing);\n existing.__tovaNodes.push(...inserted);\n }\n existing.__vnode = newVNode;\n return;\n }\n removeLogicalNode(parent, existing);\n parent.appendChild(render(newVNode));\n return;\n }\n\n // Element — patch in place\n if (existing.nodeType === 1 && newVNode.tag &&\n existing.tagName.toLowerCase() === newVNode.tag.toLowerCase()) {\n const oldVNode = existing.__vnode || { props: {}, children: [] };\n applyProps(existing, newVNode.props, oldVNode.props);\n patchChildrenOfElement(existing, flattenVNodes(newVNode.children));\n existing.__vnode = newVNode;\n return;\n }\n\n // Different type — full replace\n removeLogicalNode(parent, existing);\n parent.appendChild(render(newVNode));\n}\n\n// ─── Hydration (SSR) ─────────────────────────────────────\n// SSR renders flat HTML without markers. Hydration attaches reactivity\n// to existing DOM nodes and inserts markers for dynamic blocks.\n\n// Dev-mode hydration mismatch detection\nfunction checkHydrationMismatch(domNode, vnode) {\n if (!__DEV__) return;\n if (!domNode || !vnode || !vnode.__tova) return;\n\n const props = vnode.props || {};\n\n // Check className\n if (props.className !== undefined) {\n const expected = typeof props.className === 'function' ? props.className() : props.className;\n const actual = domNode.className || '';\n if (expected && actual !== expected) {\n console.warn(`Tova hydration mismatch: <${vnode.tag}> class expected \"${expected}\" but got \"${actual}\"`);\n }\n }\n\n // Check attributes\n for (const [key, value] of Object.entries(props)) {\n if (key === 'key' || key === 'ref' || key === 'className' || key.startsWith('on')) continue;\n if (typeof value === 'function') continue; // reactive props — skip static check\n\n if (domNode.getAttribute) {\n const attrName = key === 'className' ? 'class' : key;\n const actual = domNode.getAttribute(attrName);\n const expected = String(value);\n if (actual !== null && actual !== expected) {\n console.warn(`Tova hydration mismatch: <${vnode.tag}> attribute \"${key}\" expected \"${expected}\" but got \"${actual}\"`);\n }\n }\n }\n}\n\n// Check if a DOM node is an SSR marker comment (<!--tova-s:ID-->)\nfunction isSSRMarker(node) {\n return node && node.nodeType === 8 && typeof node.data === 'string' && node.data.startsWith('tova-s:');\n}\n\n// Find the closing SSR marker and collect content nodes between them\nfunction collectSSRMarkerContent(startMarker) {\n const id = startMarker.data.replace('tova-s:', '');\n const closingText = `/tova-s:${id}`;\n const content = [];\n let cursor = startMarker.nextSibling;\n while (cursor) {\n if (cursor.nodeType === 8 && cursor.data === closingText) {\n return { content, endMarker: cursor };\n }\n content.push(cursor);\n cursor = cursor.nextSibling;\n }\n return { content, endMarker: null };\n}\n\nfunction hydrateVNode(domNode, vnode) {\n if (!domNode) return null;\n if (vnode === null || vnode === undefined) return domNode;\n\n // Function vnode (reactive text, JSXIf, JSXFor)\n if (typeof vnode === 'function') {\n if (domNode.nodeType === 3) {\n // Dev-mode: warn if text content differs\n if (__DEV__) {\n const val = vnode();\n const expected = val == null ? '' : String(val);\n if (domNode.textContent !== expected) {\n console.warn(`Tova hydration mismatch: text expected \"${expected}\" but got \"${domNode.textContent}\"`);\n }\n }\n // Reactive text: attach effect to existing text node\n domNode.__tovaReactive = true;\n createEffect(() => {\n const val = vnode();\n const text = val == null ? '' : String(val);\n if (domNode.textContent !== text) domNode.textContent = text;\n });\n return domNode.nextSibling;\n }\n // Complex dynamic block: insert marker-based render, replace SSR node\n const parent = domNode.parentNode;\n const next = domNode.nextSibling;\n const rendered = render(vnode);\n parent.replaceChild(rendered, domNode);\n return next;\n }\n\n // Primitive text — already correct from SSR\n if (typeof vnode === 'string' || typeof vnode === 'number' || typeof vnode === 'boolean') {\n if (__DEV__ && domNode.nodeType === 3) {\n const expected = String(vnode);\n if (domNode.textContent !== expected) {\n console.warn(`Tova hydration mismatch: text expected \"${expected}\" but got \"${domNode.textContent}\"`);\n }\n }\n return domNode.nextSibling;\n }\n\n // Array\n if (Array.isArray(vnode)) {\n let cursor = domNode;\n for (const child of flattenVNodes(vnode)) {\n if (!cursor) break;\n cursor = hydrateVNode(cursor, child);\n }\n return cursor;\n }\n\n if (!vnode.__tova) return domNode.nextSibling;\n\n // Fragment — children rendered inline in SSR (no wrapper)\n if (vnode.tag === '__fragment') {\n const children = flattenVNodes(vnode.children);\n let cursor = domNode;\n for (const child of children) {\n if (!cursor) break;\n cursor = hydrateVNode(cursor, child);\n }\n return cursor;\n }\n\n // Dynamic node — SSR marker-aware hydration\n if (vnode.tag === '__dynamic' && typeof vnode.compute === 'function') {\n // Check if current domNode is an SSR marker (<!--tova-s:ID-->)\n if (isSSRMarker(domNode)) {\n const { content, endMarker } = collectSSRMarkerContent(domNode);\n const parent = domNode.parentNode;\n\n // Remove SSR markers and content, replace with reactive marker\n const afterEnd = endMarker ? endMarker.nextSibling : null;\n for (const node of content) {\n if (node.parentNode === parent) parent.removeChild(node);\n }\n if (endMarker && endMarker.parentNode === parent) parent.removeChild(endMarker);\n\n const rendered = render(vnode);\n parent.replaceChild(rendered, domNode);\n return afterEnd;\n }\n\n // No SSR marker — fall back to standard behavior\n const parent = domNode.parentNode;\n const next = domNode.nextSibling;\n const rendered = render(vnode);\n parent.replaceChild(rendered, domNode);\n return next;\n }\n\n // Element — attach event handlers, reactive props, refs\n if (domNode.nodeType === 1 && domNode.tagName.toLowerCase() === vnode.tag.toLowerCase()) {\n if (__DEV__) checkHydrationMismatch(domNode, vnode);\n hydrateProps(domNode, vnode.props);\n domNode.__vnode = vnode;\n\n const children = flattenVNodes(vnode.children || []);\n let cursor = domNode.firstChild;\n for (const child of children) {\n if (!cursor) break;\n cursor = hydrateVNode(cursor, child);\n }\n return domNode.nextSibling;\n }\n\n // Tag mismatch — fall back to full render\n if (__DEV__) {\n const expectedTag = vnode.tag || '(unknown)';\n const actualTag = domNode.tagName ? domNode.tagName.toLowerCase() : `nodeType:${domNode.nodeType}`;\n console.warn(`Tova hydration mismatch: expected <${expectedTag}> but got <${actualTag}>, falling back to full render`);\n }\n const parent = domNode.parentNode;\n const next = domNode.nextSibling;\n const rendered = render(vnode);\n parent.replaceChild(rendered, domNode);\n return next;\n}\n\nfunction hydrateProps(el, props) {\n for (const [key, value] of Object.entries(props)) {\n if (key === 'ref') {\n if (typeof value === 'object' && value !== null && 'current' in value) {\n value.current = el;\n } else if (typeof value === 'function') {\n value(el);\n }\n } else if (key.startsWith('on')) {\n const eventName = key.slice(2).toLowerCase();\n el.addEventListener(eventName, value);\n if (!el.__handlers) el.__handlers = {};\n el.__handlers[eventName] = value;\n } else if (key === 'key') {\n // Skip\n } else if (typeof value === 'function' && !key.startsWith('on')) {\n createEffect(() => {\n const val = value();\n applyPropValue(el, key, val);\n });\n }\n }\n}\n\nexport function hydrate(component, container) {\n if (!container) {\n console.error('Tova: Hydration target not found');\n return;\n }\n\n const startTime = typeof performance !== 'undefined' ? performance.now() : 0;\n\n const result = createRoot(() => {\n const vnode = typeof component === 'function' ? component() : component;\n if (container.firstChild) {\n hydrateVNode(container.firstChild, vnode);\n }\n });\n\n // Dispatch hydration completion event\n const duration = typeof performance !== 'undefined' ? performance.now() - startTime : 0;\n if (typeof CustomEvent !== 'undefined' && typeof container.dispatchEvent === 'function') {\n container.dispatchEvent(new CustomEvent('tova:hydrated', { detail: { duration }, bubbles: true }));\n }\n\n if (__devtools_hooks && __devtools_hooks.onHydrate) {\n __devtools_hooks.onHydrate({ duration });\n }\n\n return result;\n}\n\nexport function mount(component, container) {\n if (!container) {\n console.error('Tova: Mount target not found');\n return;\n }\n\n const result = createRoot((dispose) => {\n const vnode = typeof component === 'function' ? component() : component;\n container.innerHTML = '';\n container.appendChild(render(vnode));\n return dispose;\n });\n\n if (__devtools_hooks && __devtools_hooks.onMount) {\n __devtools_hooks.onMount();\n }\n\n return result;\n}\n\n// ─── Progressive Hydration ──────────────────────────────────\n// Hydrate a component only when it becomes visible in the viewport.\n\nexport function hydrateWhenVisible(component, domNode, options = {}) {\n if (typeof IntersectionObserver === 'undefined') {\n // Fallback: hydrate immediately\n return hydrate(component, domNode);\n }\n\n const { rootMargin = '200px' } = options;\n let hydrated = false;\n\n const observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (entry.isIntersecting && !hydrated) {\n hydrated = true;\n observer.disconnect();\n hydrate(component, domNode);\n }\n }\n },\n { rootMargin },\n );\n\n observer.observe(domNode);\n\n return () => {\n observer.disconnect();\n };\n}\n";
|
|
3
|
+
export const REACTIVITY_SOURCE = "// Fine-grained reactivity system for Tova (signals-based)\n\nconst __DEV__ = typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production';\n\n// ─── DevTools hooks (zero-cost when disabled) ────────────\nlet __devtools_hooks = null;\nexport function __enableDevTools(hooks) {\n __devtools_hooks = hooks;\n}\n\nlet currentEffect = null;\nconst effectStack = [];\n\n// ─── Ownership System ─────────────────────────────────────\nlet currentOwner = null;\nconst ownerStack = [];\n\n// ─── Batching ────────────────────────────────────────────\n// Default: synchronous flush after each setter (backward compatible).\n// Inside batch(): effects are deferred and flushed once when batch ends.\n// This means setA(1); setB(2) causes 2 runs by default, but\n// batch(() => { setA(1); setB(2); }) causes only 1 run.\n\nlet pendingEffects = new Set();\nlet batchDepth = 0;\nlet flushing = false;\n\nfunction flush() {\n if (flushing) return; // prevent re-entrant flush\n flushing = true;\n let iterations = 0;\n try {\n while (pendingEffects.size > 0) {\n if (++iterations > 100) {\n console.error('Tova: Possible infinite loop in reactive updates (>100 flush iterations). Aborting.');\n pendingEffects.clear();\n break;\n }\n const toRun = [...pendingEffects];\n pendingEffects.clear();\n for (const effect of toRun) {\n if (!effect._disposed) {\n effect();\n }\n }\n }\n } finally {\n flushing = false;\n }\n}\n\nexport function batch(fn) {\n batchDepth++;\n try {\n fn();\n } finally {\n batchDepth--;\n if (batchDepth === 0) {\n flush();\n }\n }\n}\n\n// ─── Ownership Root ──────────────────────────────────────\n\nexport function createRoot(fn) {\n const root = {\n _children: [],\n _disposed: false,\n _cleanups: [],\n _contexts: null,\n _owner: currentOwner,\n dispose() {\n if (root._disposed) return;\n root._disposed = true;\n // Dispose children in reverse order\n for (let i = root._children.length - 1; i >= 0; i--) {\n const child = root._children[i];\n if (typeof child.dispose === 'function') child.dispose();\n }\n root._children.length = 0;\n // Run cleanups in reverse order\n for (let i = root._cleanups.length - 1; i >= 0; i--) {\n try { root._cleanups[i](); } catch (e) { console.error('Tova: root cleanup error:', e); }\n }\n root._cleanups.length = 0;\n }\n };\n ownerStack.push(currentOwner);\n currentOwner = root;\n try {\n return fn(root.dispose.bind(root));\n } finally {\n currentOwner = ownerStack.pop();\n }\n}\n\n// ─── Dependency Cleanup ──────────────────────────────────\n\nfunction cleanupDeps(subscriber) {\n if (subscriber._deps) {\n for (const depSet of subscriber._deps) {\n depSet.delete(subscriber);\n }\n subscriber._deps.clear();\n }\n}\n\nfunction trackDep(subscriber, subscriberSet) {\n subscriberSet.add(subscriber);\n if (!subscriber._deps) subscriber._deps = new Set();\n subscriber._deps.add(subscriberSet);\n}\n\n// ─── Signals ─────────────────────────────────────────────\n\nexport function createSignal(initialValue, name) {\n let value = initialValue;\n const subscribers = new Set();\n let signalId = null;\n\n if (__devtools_hooks) {\n signalId = __devtools_hooks.onSignalCreate(\n () => value,\n (v) => setter(v),\n name,\n );\n }\n\n function getter() {\n if (currentEffect) {\n trackDep(currentEffect, subscribers);\n }\n return value;\n }\n\n function setter(newValue) {\n if (typeof newValue === 'function') {\n newValue = newValue(value);\n }\n if (value !== newValue) {\n const oldValue = value;\n value = newValue;\n if (__devtools_hooks && signalId != null) {\n __devtools_hooks.onSignalUpdate(signalId, oldValue, newValue);\n }\n for (const sub of [...subscribers]) {\n if (sub._isComputed) {\n sub(); // propagate dirty flags synchronously through computed graph\n } else {\n pendingEffects.add(sub);\n }\n }\n if (batchDepth === 0) {\n flush();\n }\n }\n }\n\n return [getter, setter];\n}\n\n// ─── Effects ─────────────────────────────────────────────\n\nfunction runCleanups(effect) {\n if (effect._cleanup) {\n try { effect._cleanup(); } catch (e) { console.error('Tova: cleanup error:', e); }\n effect._cleanup = null;\n }\n if (effect._cleanups && effect._cleanups.length > 0) {\n for (const cb of effect._cleanups) {\n try { cb(); } catch (e) { console.error('Tova: cleanup error:', e); }\n }\n effect._cleanups = [];\n }\n}\n\nexport function createEffect(fn) {\n function effect() {\n if (effect._running) return;\n if (effect._disposed) return;\n effect._running = true;\n\n // Run cleanups from previous execution\n runCleanups(effect);\n\n // Remove from all previous dependency subscriber sets\n cleanupDeps(effect);\n\n effectStack.push(effect);\n currentEffect = effect;\n const startTime = __devtools_hooks && typeof performance !== 'undefined' ? performance.now() : 0;\n try {\n const result = fn();\n // If effect returns a function, use as cleanup\n if (typeof result === 'function') {\n effect._cleanup = result;\n }\n } catch (e) {\n console.error('Tova: Error in effect:', e);\n if (currentErrorHandler) {\n currentErrorHandler(e);\n }\n } finally {\n if (__devtools_hooks) {\n const duration = typeof performance !== 'undefined' ? performance.now() - startTime : 0;\n __devtools_hooks.onEffectRun(effect, duration);\n }\n effectStack.pop();\n currentEffect = effectStack[effectStack.length - 1] || null;\n effect._running = false;\n }\n }\n\n effect._deps = new Set();\n effect._running = false;\n effect._disposed = false;\n effect._cleanup = null;\n effect._cleanups = [];\n effect._owner = currentOwner;\n\n if (__devtools_hooks) {\n __devtools_hooks.onEffectCreate(effect);\n }\n\n if (currentOwner && !currentOwner._disposed) {\n currentOwner._children.push(effect);\n }\n\n effect.dispose = function () {\n effect._disposed = true;\n runCleanups(effect);\n cleanupDeps(effect);\n pendingEffects.delete(effect);\n // Remove from owner's children\n if (effect._owner) {\n const idx = effect._owner._children.indexOf(effect);\n if (idx >= 0) effect._owner._children.splice(idx, 1);\n }\n };\n\n // Run immediately (synchronous first run)\n effect();\n return effect;\n}\n\n// ─── Computed (lazy/pull-based for glitch-free reads) ────\n\nexport function createComputed(fn) {\n let value;\n let dirty = true;\n const subscribers = new Set();\n\n // notify is called synchronously when a source signal changes.\n // It marks the computed dirty and propagates to downstream subscribers.\n function notify() {\n if (!dirty) {\n dirty = true;\n for (const sub of [...subscribers]) {\n if (sub._isComputed) {\n sub(); // cascade dirty flags synchronously\n } else {\n pendingEffects.add(sub);\n }\n }\n }\n }\n\n notify._deps = new Set();\n notify._disposed = false;\n notify._isComputed = true;\n notify._owner = currentOwner;\n\n if (currentOwner && !currentOwner._disposed) {\n currentOwner._children.push(notify);\n }\n\n notify.dispose = function () {\n notify._disposed = true;\n cleanupDeps(notify);\n if (notify._owner) {\n const idx = notify._owner._children.indexOf(notify);\n if (idx >= 0) notify._owner._children.splice(idx, 1);\n }\n };\n\n function recompute() {\n cleanupDeps(notify);\n\n effectStack.push(notify);\n currentEffect = notify;\n try {\n value = fn();\n dirty = false;\n } finally {\n effectStack.pop();\n currentEffect = effectStack[effectStack.length - 1] || null;\n }\n }\n\n // Initial computation\n recompute();\n\n function getter() {\n if (currentEffect) {\n trackDep(currentEffect, subscribers);\n }\n if (dirty) {\n recompute();\n }\n return value;\n }\n\n return getter;\n}\n\n// ─── Lifecycle Hooks ─────────────────────────────────────\n\nexport function onMount(fn) {\n const owner = currentOwner;\n queueMicrotask(() => {\n const result = fn();\n if (typeof result === 'function' && owner && !owner._disposed) {\n owner._cleanups.push(result);\n }\n });\n}\n\nexport function onUnmount(fn) {\n if (currentOwner && !currentOwner._disposed) {\n currentOwner._cleanups.push(fn);\n }\n}\n\nexport function onCleanup(fn) {\n if (currentEffect) {\n if (!currentEffect._cleanups) currentEffect._cleanups = [];\n currentEffect._cleanups.push(fn);\n }\n}\n\n// ─── Untrack ─────────────────────────────────────────────\n// Run a function without tracking any signal reads (opt out of reactivity)\n\nexport function untrack(fn) {\n const prev = currentEffect;\n currentEffect = null;\n try {\n return fn();\n } finally {\n currentEffect = prev;\n }\n}\n\n// ─── Watch ───────────────────────────────────────────────\n// Watch a reactive expression, calling callback with (newValue, oldValue)\n// Returns a dispose function to stop watching.\n\nexport function watch(getter, callback, options = {}) {\n let oldValue = undefined;\n let initialized = false;\n\n const effect = createEffect(() => {\n const newValue = getter();\n if (initialized) {\n untrack(() => callback(newValue, oldValue));\n } else if (options.immediate) {\n untrack(() => callback(newValue, undefined));\n }\n oldValue = newValue;\n initialized = true;\n });\n\n return effect.dispose ? effect.dispose.bind(effect) : () => {\n effect._disposed = true;\n runCleanups(effect);\n cleanupDeps(effect);\n pendingEffects.delete(effect);\n };\n}\n\n// ─── Refs ────────────────────────────────────────────────\n\nexport function createRef(initialValue) {\n return { current: initialValue !== undefined ? initialValue : null };\n}\n\n// ─── Error Boundaries ────────────────────────────────────\n\n// Stack-based error handler for correct nested boundary propagation\nconst errorHandlerStack = [];\nlet currentErrorHandler = null;\n\nfunction pushErrorHandler(handler) {\n errorHandlerStack.push(currentErrorHandler);\n currentErrorHandler = handler;\n}\n\nfunction popErrorHandler() {\n currentErrorHandler = errorHandlerStack.pop() || null;\n}\n\n// Component name tracking for stack traces\nconst componentNameStack = [];\n\nexport function pushComponentName(name) {\n componentNameStack.push(name);\n}\n\nexport function popComponentName() {\n componentNameStack.pop();\n}\n\nfunction buildComponentStack() {\n return [...componentNameStack].reverse();\n}\n\nexport function createErrorBoundary(options = {}) {\n const { onError, onReset } = options;\n const [error, setError] = createSignal(null);\n\n function run(fn) {\n pushErrorHandler((e) => {\n const stack = buildComponentStack();\n if (e && typeof e === 'object') e.__tovaComponentStack = stack;\n setError(e);\n if (onError) onError({ error: e, componentStack: stack });\n });\n try {\n return fn();\n } catch (e) {\n const stack = buildComponentStack();\n if (e && typeof e === 'object') e.__tovaComponentStack = stack;\n setError(e);\n if (onError) onError({ error: e, componentStack: stack });\n return null;\n } finally {\n popErrorHandler();\n }\n }\n\n function reset() {\n setError(null);\n if (onReset) onReset();\n }\n\n return { error, run, reset };\n}\n\nexport function ErrorBoundary({ fallback, children, onError, onReset, retry = 0 }) {\n const [error, setError] = createSignal(null);\n const [retryCount, setRetryCount] = createSignal(0);\n\n function handleError(e) {\n const stack = buildComponentStack();\n if (e && typeof e === 'object') e.__tovaComponentStack = stack;\n if (retryCount() < retry) {\n setRetryCount(c => c + 1);\n setError(null); // clear to re-trigger render\n return;\n }\n setError(e);\n if (onError) onError({ error: e, componentStack: stack });\n }\n\n // Return a reactive wrapper that switches between children and fallback\n const childContent = children && children.length === 1 ? children[0] : tova_fragment(children || []);\n\n const vnode = {\n __tova: true,\n tag: '__dynamic',\n props: {},\n children: [],\n _fallback: fallback,\n _componentName: 'ErrorBoundary',\n _errorHandler: handleError, // Active during __dynamic effect render cycle\n compute: () => {\n const err = error();\n if (err) {\n // Render fallback — if fallback itself throws, propagate to parent boundary\n try {\n return typeof fallback === 'function'\n ? fallback({\n error: err,\n reset: () => {\n setRetryCount(0);\n setError(null);\n if (onReset) onReset();\n },\n })\n : fallback;\n } catch (fallbackError) {\n // Fallback threw — propagate to parent error boundary\n if (currentErrorHandler) {\n currentErrorHandler(fallbackError);\n }\n return null;\n }\n }\n return childContent;\n },\n };\n\n return vnode;\n}\n\n// ─── Dynamic Component ──────────────────────────────────\n// Renders a component dynamically based on a reactive signal.\n// Usage: Dynamic({ component: mySignal, ...props })\n\nexport function Dynamic({ component, ...rest }) {\n return {\n __tova: true,\n tag: '__dynamic',\n props: {},\n children: [],\n compute: () => {\n const comp = typeof component === 'function' && !component.__tova ? component() : component;\n if (!comp) return null;\n if (typeof comp === 'function') {\n return comp(rest);\n }\n return comp;\n },\n };\n}\n\n// ─── Portal ─────────────────────────────────────────────\n// Renders children into a different DOM target.\n// Usage: Portal({ target: \"#modal-root\", children })\n\nexport function Portal({ target, children }) {\n return {\n __tova: true,\n tag: '__portal',\n props: { target },\n children: children || [],\n };\n}\n\n// ─── Lazy ───────────────────────────────────────────────\n// Async component loading with optional fallback.\n// Usage: const LazyComp = lazy(() => import('./HeavyComponent.js'))\n\nexport function lazy(loader) {\n let resolved = null;\n let loadError = null;\n let promise = null;\n\n return function LazyWrapper(props) {\n if (resolved) {\n return resolved(props);\n }\n\n if (!promise) {\n promise = loader()\n .then(mod => {\n resolved = mod.default || mod;\n })\n .catch(e => { loadError = e; });\n }\n\n const [tick, setTick] = createSignal(0);\n\n // Trigger re-render when promise settles\n promise.then(() => setTick(1)).catch(() => setTick(1));\n\n return {\n __tova: true,\n tag: '__dynamic',\n props: {},\n children: [],\n compute: () => {\n tick(); // Track for reactivity\n if (loadError) return tova_el('span', { className: 'tova-error' }, [String(loadError)]);\n if (resolved) return resolved(props);\n // Fallback while loading\n return props && props.fallback ? props.fallback : null;\n },\n };\n };\n}\n\n// ─── Context (Provide/Inject) ────────────────────────────\n// Tree-based: values are stored on the ownership tree, inject walks up.\n\nexport function createContext(defaultValue) {\n const id = Symbol('context');\n return { _id: id, _default: defaultValue };\n}\n\nexport function provide(context, value) {\n const owner = currentOwner;\n if (owner) {\n if (!owner._contexts) owner._contexts = new Map();\n owner._contexts.set(context._id, value);\n }\n}\n\nexport function inject(context) {\n let owner = currentOwner;\n while (owner) {\n if (owner._contexts && owner._contexts.has(context._id)) {\n return owner._contexts.get(context._id);\n }\n owner = owner._owner;\n }\n return context._default;\n}\n\n// ─── DOM Rendering ────────────────────────────────────────\n\n// Inject scoped CSS into the page (idempotent — only injects once per id)\nconst __tovaInjectedStyles = new Set();\nexport function tova_inject_css(id, css) {\n if (__tovaInjectedStyles.has(id)) return;\n __tovaInjectedStyles.add(id);\n const style = document.createElement('style');\n style.setAttribute('data-tova-style', id);\n style.textContent = css;\n document.head.appendChild(style);\n}\n\nexport function tova_el(tag, props = {}, children = []) {\n return { __tova: true, tag, props, children };\n}\n\nexport function tova_fragment(children) {\n return { __tova: true, tag: '__fragment', props: {}, children };\n}\n\n// Inject a key prop into a vnode for keyed reconciliation\nexport function tova_keyed(key, vnode) {\n if (vnode && vnode.__tova) {\n vnode.props = { ...vnode.props, key };\n }\n return vnode;\n}\n\n// Flatten nested arrays and vnodes into a flat list of vnodes\nfunction flattenVNodes(children) {\n const result = [];\n for (const child of children) {\n if (child === null || child === undefined) {\n continue;\n } else if (Array.isArray(child)) {\n result.push(...flattenVNodes(child));\n } else {\n result.push(child);\n }\n }\n return result;\n}\n\n// ─── Marker-based DOM helpers ─────────────────────────────\n// Instead of wrapping dynamic blocks/fragments in <span style=\"display:contents\">,\n// we use comment node markers. A marker's __tovaNodes tracks its content nodes.\n// Content nodes have __tovaOwner pointing to their owning marker.\n\n// Recursively dispose ownership roots attached to a DOM subtree\nfunction disposeNode(node) {\n if (!node) return;\n if (node.__tovaRoot) {\n node.__tovaRoot();\n node.__tovaRoot = null;\n }\n // If this is a marker, dispose and remove its content nodes\n if (node.__tovaNodes) {\n for (const cn of node.__tovaNodes) {\n disposeNode(cn);\n if (cn.parentNode) cn.parentNode.removeChild(cn);\n }\n node.__tovaNodes = [];\n }\n if (node.childNodes) {\n for (const child of Array.from(node.childNodes)) {\n disposeNode(child);\n }\n }\n}\n\n// Check if a node is transitively owned by a marker (walks __tovaOwner chain)\nfunction isOwnedBy(node, marker) {\n let owner = node.__tovaOwner;\n while (owner) {\n if (owner === marker) return true;\n owner = owner.__tovaOwner;\n }\n return false;\n}\n\n// Get logical children of a parent element (skips marker content nodes)\nfunction getLogicalChildren(parent) {\n const logical = [];\n for (let i = 0; i < parent.childNodes.length; i++) {\n const node = parent.childNodes[i];\n if (!node.__tovaOwner) {\n logical.push(node);\n }\n }\n return logical;\n}\n\n// Find the first DOM sibling after all of a marker's content\nfunction nextSiblingAfterMarker(marker) {\n if (!marker.__tovaNodes || marker.__tovaNodes.length === 0) {\n return marker.nextSibling;\n }\n let last = marker.__tovaNodes[marker.__tovaNodes.length - 1];\n // If last content is itself a marker, recurse to find physical end\n while (last && last.__tovaNodes && last.__tovaNodes.length > 0) {\n last = last.__tovaNodes[last.__tovaNodes.length - 1];\n }\n return last ? last.nextSibling : marker.nextSibling;\n}\n\n// Remove a logical node (marker + its content, or a regular node) from the DOM\nfunction removeLogicalNode(parent, node) {\n disposeNode(node);\n if (node.parentNode === parent) parent.removeChild(node);\n}\n\n// Insert rendered result (could be single node or DocumentFragment) before ref,\n// setting __tovaOwner on top-level inserted nodes. Returns array of inserted nodes.\nfunction insertRendered(parent, rendered, ref, owner) {\n if (rendered.nodeType === 11) {\n const nodes = Array.from(rendered.childNodes);\n for (const n of nodes) {\n if (!n.__tovaOwner) n.__tovaOwner = owner;\n }\n parent.insertBefore(rendered, ref);\n return nodes;\n }\n if (!rendered.__tovaOwner) rendered.__tovaOwner = owner;\n parent.insertBefore(rendered, ref);\n return [rendered];\n}\n\n// Clear a marker's content from the DOM and reset __tovaNodes\nfunction clearMarkerContent(marker) {\n for (const node of marker.__tovaNodes) {\n disposeNode(node);\n if (node.parentNode) node.parentNode.removeChild(node);\n }\n marker.__tovaNodes = [];\n}\n\n// ─── Render ───────────────────────────────────────────────\n\n// Create real DOM nodes from a vnode (with fine-grained reactive bindings).\n// Returns a single DOM node for elements/text, or a DocumentFragment for\n// markers (dynamic blocks, fragments) containing [marker, ...content].\nexport function render(vnode) {\n if (vnode === null || vnode === undefined) {\n return document.createTextNode('');\n }\n\n // Reactive dynamic block (JSXIf, JSXFor, reactive text, etc.)\n if (typeof vnode === 'function') {\n const marker = document.createComment('');\n marker.__tovaDynamic = true;\n marker.__tovaNodes = [];\n\n const frag = document.createDocumentFragment();\n frag.appendChild(marker);\n\n createEffect(() => {\n const val = vnode();\n const parent = marker.parentNode;\n const ref = nextSiblingAfterMarker(marker);\n\n // Array: keyed or positional reconciliation within marker range\n if (Array.isArray(val)) {\n const flat = flattenVNodes(val);\n const hasKeys = flat.some(c => getKey(c) != null);\n if (hasKeys) {\n patchKeyedInMarker(marker, flat);\n } else {\n patchPositionalInMarker(marker, flat);\n }\n return;\n }\n\n // Text: optimize single text node update in place\n if (val == null || typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {\n const text = val == null ? '' : String(val);\n if (marker.__tovaNodes.length === 1 && marker.__tovaNodes[0].nodeType === 3) {\n if (marker.__tovaNodes[0].textContent !== text) {\n marker.__tovaNodes[0].textContent = text;\n }\n return;\n }\n clearMarkerContent(marker);\n const textNode = document.createTextNode(text);\n textNode.__tovaOwner = marker;\n parent.insertBefore(textNode, ref);\n marker.__tovaNodes = [textNode];\n return;\n }\n\n // Vnode or other: clear and re-render\n clearMarkerContent(marker);\n if (val && val.__tova) {\n const rendered = render(val);\n marker.__tovaNodes = insertRendered(parent, rendered, ref, marker);\n } else {\n const textNode = document.createTextNode(String(val));\n textNode.__tovaOwner = marker;\n parent.insertBefore(textNode, ref);\n marker.__tovaNodes = [textNode];\n }\n });\n\n return frag;\n }\n\n if (typeof vnode === 'string' || typeof vnode === 'number' || typeof vnode === 'boolean') {\n return document.createTextNode(String(vnode));\n }\n\n if (Array.isArray(vnode)) {\n const fragment = document.createDocumentFragment();\n for (const child of vnode) {\n fragment.appendChild(render(child));\n }\n return fragment;\n }\n\n if (!vnode.__tova) {\n return document.createTextNode(String(vnode));\n }\n\n // Fragment — marker + children (no wrapper element)\n if (vnode.tag === '__fragment') {\n const marker = document.createComment('');\n marker.__tovaFragment = true;\n marker.__tovaNodes = [];\n marker.__vnode = vnode;\n\n const frag = document.createDocumentFragment();\n frag.appendChild(marker);\n\n for (const child of flattenVNodes(vnode.children)) {\n const rendered = render(child);\n const inserted = insertRendered(frag, rendered, null, marker);\n marker.__tovaNodes.push(...inserted);\n }\n\n return frag;\n }\n\n // Dynamic reactive node (ErrorBoundary, Dynamic component, etc.)\n if (vnode.tag === '__dynamic' && typeof vnode.compute === 'function') {\n const marker = document.createComment('');\n marker.__tovaDynamic = true;\n marker.__tovaNodes = [];\n\n const frag = document.createDocumentFragment();\n frag.appendChild(marker);\n\n let prevDispose = null;\n const errHandler = vnode._errorHandler || null;\n createEffect(() => {\n if (errHandler) pushErrorHandler(errHandler);\n try {\n const inner = vnode.compute();\n const parent = marker.parentNode;\n const ref = nextSiblingAfterMarker(marker);\n\n if (prevDispose) {\n prevDispose();\n prevDispose = null;\n }\n clearMarkerContent(marker);\n\n createRoot((dispose) => {\n prevDispose = dispose;\n const rendered = render(inner);\n marker.__tovaNodes = insertRendered(parent, rendered, ref, marker);\n });\n } catch (e) {\n if (errHandler) {\n errHandler(e);\n } else if (currentErrorHandler) {\n currentErrorHandler(e);\n } else {\n console.error('Uncaught error during render:', e);\n }\n } finally {\n if (errHandler) popErrorHandler();\n }\n });\n\n return frag;\n }\n\n // Portal — render children into a different DOM target\n if (vnode.tag === '__portal') {\n const placeholder = document.createComment('portal');\n const targetSelector = vnode.props.target;\n queueMicrotask(() => {\n const targetEl = typeof targetSelector === 'string'\n ? document.querySelector(targetSelector)\n : targetSelector;\n if (targetEl) {\n for (const child of flattenVNodes(vnode.children)) {\n targetEl.appendChild(render(child));\n }\n }\n });\n return placeholder;\n }\n\n // Element\n const el = document.createElement(vnode.tag);\n applyReactiveProps(el, vnode.props);\n\n // Set data-tova-component attribute for DevTools\n if (vnode._componentName) {\n el.setAttribute('data-tova-component', vnode._componentName);\n if (__devtools_hooks && __devtools_hooks.onComponentRender) {\n __devtools_hooks.onComponentRender(vnode._componentName, el, 0);\n }\n }\n\n // Render children\n for (const child of flattenVNodes(vnode.children)) {\n el.appendChild(render(child));\n }\n\n // Store vnode reference for patching\n el.__vnode = vnode;\n\n return el;\n}\n\n// Apply reactive props — function-valued props get their own effect\nfunction applyReactiveProps(el, props) {\n for (const [key, value] of Object.entries(props)) {\n if (key === 'ref') {\n if (typeof value === 'object' && value !== null && 'current' in value) {\n value.current = el;\n } else if (typeof value === 'function') {\n value(el);\n }\n } else if (key.startsWith('on')) {\n const eventName = key.slice(2).toLowerCase();\n el.addEventListener(eventName, value);\n if (!el.__handlers) el.__handlers = {};\n el.__handlers[eventName] = value;\n } else if (key === 'key') {\n // Skip\n } else if (typeof value === 'function' && !key.startsWith('on')) {\n // Reactive prop — create effect for fine-grained updates\n createEffect(() => {\n const val = value();\n applyPropValue(el, key, val);\n });\n } else {\n applyPropValue(el, key, value);\n }\n }\n}\n\nfunction applyPropValue(el, key, val) {\n if (key === 'className') {\n if (el.className !== val) el.className = val || '';\n } else if (key === 'innerHTML' || key === 'dangerouslySetInnerHTML') {\n const html = typeof val === 'object' && val !== null ? val.__html || '' : val || '';\n if (el.innerHTML !== html) el.innerHTML = html;\n } else if (key === 'value') {\n if (el !== document.activeElement && el.value !== val) {\n el.value = val;\n }\n } else if (key === 'checked') {\n el.checked = !!val;\n } else if (key === 'disabled' || key === 'readOnly' || key === 'hidden') {\n el[key] = !!val;\n } else if (key === 'style' && typeof val === 'object') {\n // Clear old properties not present in new style object\n for (let i = el.style.length - 1; i >= 0; i--) {\n const prop = el.style[i];\n const camel = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());\n if (!(prop in val) && !(camel in val)) {\n el.style.removeProperty(prop);\n }\n }\n Object.assign(el.style, val);\n } else {\n const s = val == null ? '' : String(val);\n if (el.getAttribute(key) !== s) {\n el.setAttribute(key, s);\n }\n }\n}\n\n// Apply/update props on a DOM element (used by patcher for full-tree mode)\nfunction applyProps(el, newProps, oldProps) {\n // Remove old props that are no longer present\n for (const key of Object.keys(oldProps)) {\n if (!(key in newProps)) {\n if (key.startsWith('on')) {\n const eventName = key.slice(2).toLowerCase();\n if (el.__handlers && el.__handlers[eventName]) {\n el.removeEventListener(eventName, el.__handlers[eventName]);\n delete el.__handlers[eventName];\n }\n } else if (key === 'className') {\n el.className = '';\n } else if (key === 'style') {\n el.removeAttribute('style');\n } else {\n el.removeAttribute(key);\n }\n }\n }\n\n // Apply new props\n for (const [key, value] of Object.entries(newProps)) {\n if (key === 'className') {\n const val = typeof value === 'function' ? value() : value;\n if (el.className !== val) el.className = val;\n } else if (key === 'ref') {\n if (typeof value === 'object' && value !== null && 'current' in value) {\n value.current = el;\n } else if (typeof value === 'function') {\n value(el);\n }\n } else if (key.startsWith('on')) {\n const eventName = key.slice(2).toLowerCase();\n const oldHandler = el.__handlers && el.__handlers[eventName];\n if (oldHandler !== value) {\n if (oldHandler) el.removeEventListener(eventName, oldHandler);\n el.addEventListener(eventName, value);\n if (!el.__handlers) el.__handlers = {};\n el.__handlers[eventName] = value;\n }\n } else if (key === 'style' && typeof value === 'object') {\n Object.assign(el.style, value);\n } else if (key === 'key') {\n // Skip\n } else if (key === 'value') {\n const val = typeof value === 'function' ? value() : value;\n if (el !== document.activeElement && el.value !== val) {\n el.value = val;\n }\n } else if (key === 'checked') {\n el.checked = !!value;\n } else {\n const val = typeof value === 'function' ? value() : value;\n if (el.getAttribute(key) !== String(val)) {\n el.setAttribute(key, val);\n }\n }\n }\n}\n\n// ─── Keyed Reconciliation ────────────────────────────────\n\nfunction getKey(vnode) {\n if (vnode && vnode.__tova && vnode.props) return vnode.props.key;\n return undefined;\n}\n\nfunction getNodeKey(node) {\n if (node && node.__vnode && node.__vnode.props) return node.__vnode.props.key;\n return undefined;\n}\n\n// Keyed reconciliation within a marker's content range\nfunction patchKeyedInMarker(marker, newVNodes) {\n const parent = marker.parentNode;\n const oldNodes = [...marker.__tovaNodes];\n const oldKeyMap = new Map();\n\n for (const node of oldNodes) {\n const key = getNodeKey(node);\n if (key != null) oldKeyMap.set(key, node);\n }\n\n const newNodes = [];\n const usedOld = new Set();\n\n for (const newChild of newVNodes) {\n const key = getKey(newChild);\n\n if (key != null && oldKeyMap.has(key)) {\n const oldNode = oldKeyMap.get(key);\n usedOld.add(oldNode);\n\n if (oldNode.nodeType === 1 && newChild.__tova &&\n oldNode.tagName.toLowerCase() === newChild.tag.toLowerCase()) {\n const oldVNode = oldNode.__vnode || { props: {}, children: [] };\n applyProps(oldNode, newChild.props, oldVNode.props);\n patchChildrenOfElement(oldNode, flattenVNodes(newChild.children));\n oldNode.__vnode = newChild;\n newNodes.push(oldNode);\n } else {\n const node = render(newChild);\n // render may return Fragment — collect nodes\n if (node.nodeType === 11) {\n const nodes = Array.from(node.childNodes);\n for (const n of nodes) { if (!n.__tovaOwner) n.__tovaOwner = marker; }\n parent.insertBefore(node, nextSiblingAfterMarker(marker));\n newNodes.push(...nodes);\n } else {\n if (!node.__tovaOwner) node.__tovaOwner = marker;\n newNodes.push(node);\n }\n }\n } else {\n const node = render(newChild);\n if (node.nodeType === 11) {\n const nodes = Array.from(node.childNodes);\n for (const n of nodes) { if (!n.__tovaOwner) n.__tovaOwner = marker; }\n parent.insertBefore(node, nextSiblingAfterMarker(marker));\n newNodes.push(...nodes);\n } else {\n if (!node.__tovaOwner) node.__tovaOwner = marker;\n newNodes.push(node);\n }\n }\n }\n\n // Remove unused old nodes\n for (const node of oldNodes) {\n if (!usedOld.has(node)) {\n disposeNode(node);\n if (node.parentNode === parent) parent.removeChild(node);\n }\n }\n\n // Arrange in correct order after marker using cursor approach\n let cursor = marker.nextSibling;\n for (const node of newNodes) {\n if (node === cursor) {\n cursor = node.nextSibling;\n } else {\n parent.insertBefore(node, cursor);\n }\n }\n\n marker.__tovaNodes = newNodes;\n}\n\n// Positional reconciliation within a marker's content range\nfunction patchPositionalInMarker(marker, newChildren) {\n const parent = marker.parentNode;\n const oldNodes = [...marker.__tovaNodes];\n const oldCount = oldNodes.length;\n const newCount = newChildren.length;\n\n // Patch in place\n const patchCount = Math.min(oldCount, newCount);\n for (let i = 0; i < patchCount; i++) {\n patchSingle(parent, oldNodes[i], newChildren[i]);\n }\n\n // Append new children\n const ref = nextSiblingAfterMarker(marker);\n for (let i = oldCount; i < newCount; i++) {\n const rendered = render(newChildren[i]);\n const inserted = insertRendered(parent, rendered, ref, marker);\n oldNodes.push(...inserted);\n }\n\n // Remove excess children\n for (let i = newCount; i < oldCount; i++) {\n disposeNode(oldNodes[i]);\n if (oldNodes[i].parentNode === parent) parent.removeChild(oldNodes[i]);\n }\n\n marker.__tovaNodes = oldNodes.slice(0, newCount);\n}\n\n// Keyed reconciliation for children of an element (not marker-based)\nfunction patchKeyedChildren(parent, newVNodes) {\n const logical = getLogicalChildren(parent);\n const oldKeyMap = new Map();\n\n for (const node of logical) {\n const key = getNodeKey(node);\n if (key != null) oldKeyMap.set(key, node);\n }\n\n const newNodes = [];\n const usedOld = new Set();\n\n for (const newChild of newVNodes) {\n const key = getKey(newChild);\n\n if (key != null && oldKeyMap.has(key)) {\n const oldNode = oldKeyMap.get(key);\n usedOld.add(oldNode);\n\n if (oldNode.nodeType === 1 && newChild.__tova &&\n oldNode.tagName.toLowerCase() === newChild.tag.toLowerCase()) {\n const oldVNode = oldNode.__vnode || { props: {}, children: [] };\n applyProps(oldNode, newChild.props, oldVNode.props);\n patchChildrenOfElement(oldNode, flattenVNodes(newChild.children));\n oldNode.__vnode = newChild;\n newNodes.push(oldNode);\n } else {\n newNodes.push(render(newChild));\n }\n } else {\n newNodes.push(render(newChild));\n }\n }\n\n // Remove unused old logical nodes\n for (const node of logical) {\n if (!usedOld.has(node) && node.parentNode === parent) {\n removeLogicalNode(parent, node);\n }\n }\n\n // Arrange in correct order\n for (let i = 0; i < newNodes.length; i++) {\n const expected = newNodes[i];\n const logicalNow = getLogicalChildren(parent);\n const current = logicalNow[i];\n if (current !== expected) {\n parent.insertBefore(expected, current || null);\n }\n }\n}\n\n// Positional reconciliation for children of an element\nfunction patchPositionalChildren(parent, newChildren) {\n const logical = getLogicalChildren(parent);\n const oldCount = logical.length;\n const newCount = newChildren.length;\n\n for (let i = 0; i < Math.min(oldCount, newCount); i++) {\n patchSingle(parent, logical[i], newChildren[i]);\n }\n\n for (let i = oldCount; i < newCount; i++) {\n parent.appendChild(render(newChildren[i]));\n }\n\n // Remove excess logical children\n const currentLogical = getLogicalChildren(parent);\n while (currentLogical.length > newCount) {\n const node = currentLogical.pop();\n removeLogicalNode(parent, node);\n }\n}\n\n// Patch children of a regular element\nfunction patchChildrenOfElement(el, newChildren) {\n const hasKeys = newChildren.some(c => getKey(c) != null);\n if (hasKeys) {\n patchKeyedChildren(el, newChildren);\n } else {\n patchPositionalChildren(el, newChildren);\n }\n}\n\n// Patch a single logical node in place\nfunction patchSingle(parent, existing, newVNode) {\n if (!existing) {\n parent.appendChild(render(newVNode));\n return;\n }\n\n if (newVNode === null || newVNode === undefined) {\n removeLogicalNode(parent, existing);\n return;\n }\n\n // Function vnode — replace with new dynamic block\n if (typeof newVNode === 'function') {\n const rendered = render(newVNode);\n if (existing.__tovaNodes) {\n // Existing is a marker — clear its content and replace\n clearMarkerContent(existing);\n parent.replaceChild(rendered, existing);\n } else {\n disposeNode(existing);\n parent.replaceChild(rendered, existing);\n }\n return;\n }\n\n // Text\n if (typeof newVNode === 'string' || typeof newVNode === 'number' || typeof newVNode === 'boolean') {\n const text = String(newVNode);\n if (existing.nodeType === 3) {\n if (existing.textContent !== text) existing.textContent = text;\n } else {\n removeLogicalNode(parent, existing);\n parent.insertBefore(document.createTextNode(text), null);\n }\n return;\n }\n\n if (!newVNode.__tova) {\n const text = String(newVNode);\n if (existing.nodeType === 3) {\n if (existing.textContent !== text) existing.textContent = text;\n } else {\n removeLogicalNode(parent, existing);\n parent.insertBefore(document.createTextNode(text), null);\n }\n return;\n }\n\n // Fragment — patch marker content\n if (newVNode.tag === '__fragment') {\n if (existing.__tovaFragment) {\n // Patch children within the marker range\n const oldNodes = [...existing.__tovaNodes];\n const newChildren = flattenVNodes(newVNode.children);\n // Simple approach: clear and re-render fragment content\n clearMarkerContent(existing);\n const ref = nextSiblingAfterMarker(existing);\n for (const child of newChildren) {\n const rendered = render(child);\n const inserted = insertRendered(parent, rendered, ref, existing);\n existing.__tovaNodes.push(...inserted);\n }\n existing.__vnode = newVNode;\n return;\n }\n removeLogicalNode(parent, existing);\n parent.appendChild(render(newVNode));\n return;\n }\n\n // Element — patch in place\n if (existing.nodeType === 1 && newVNode.tag &&\n existing.tagName.toLowerCase() === newVNode.tag.toLowerCase()) {\n const oldVNode = existing.__vnode || { props: {}, children: [] };\n applyProps(existing, newVNode.props, oldVNode.props);\n patchChildrenOfElement(existing, flattenVNodes(newVNode.children));\n existing.__vnode = newVNode;\n return;\n }\n\n // Different type — full replace\n removeLogicalNode(parent, existing);\n parent.appendChild(render(newVNode));\n}\n\n// ─── Hydration (SSR) ─────────────────────────────────────\n// SSR renders flat HTML without markers. Hydration attaches reactivity\n// to existing DOM nodes and inserts markers for dynamic blocks.\n\n// Dev-mode hydration mismatch detection\nfunction checkHydrationMismatch(domNode, vnode) {\n if (!__DEV__) return;\n if (!domNode || !vnode || !vnode.__tova) return;\n\n const props = vnode.props || {};\n\n // Check className\n if (props.className !== undefined) {\n const expected = typeof props.className === 'function' ? props.className() : props.className;\n const actual = domNode.className || '';\n if (expected && actual !== expected) {\n console.warn(`Tova hydration mismatch: <${vnode.tag}> class expected \"${expected}\" but got \"${actual}\"`);\n }\n }\n\n // Check attributes\n for (const [key, value] of Object.entries(props)) {\n if (key === 'key' || key === 'ref' || key === 'className' || key.startsWith('on')) continue;\n if (typeof value === 'function') continue; // reactive props — skip static check\n\n if (domNode.getAttribute) {\n const attrName = key === 'className' ? 'class' : key;\n const actual = domNode.getAttribute(attrName);\n const expected = String(value);\n if (actual !== null && actual !== expected) {\n console.warn(`Tova hydration mismatch: <${vnode.tag}> attribute \"${key}\" expected \"${expected}\" but got \"${actual}\"`);\n }\n }\n }\n}\n\n// Check if a DOM node is an SSR marker comment (<!--tova-s:ID-->)\nfunction isSSRMarker(node) {\n return node && node.nodeType === 8 && typeof node.data === 'string' && node.data.startsWith('tova-s:');\n}\n\n// Find the closing SSR marker and collect content nodes between them\nfunction collectSSRMarkerContent(startMarker) {\n const id = startMarker.data.replace('tova-s:', '');\n const closingText = `/tova-s:${id}`;\n const content = [];\n let cursor = startMarker.nextSibling;\n while (cursor) {\n if (cursor.nodeType === 8 && cursor.data === closingText) {\n return { content, endMarker: cursor };\n }\n content.push(cursor);\n cursor = cursor.nextSibling;\n }\n return { content, endMarker: null };\n}\n\nfunction hydrateVNode(domNode, vnode) {\n if (!domNode) return null;\n if (vnode === null || vnode === undefined) return domNode;\n\n // Function vnode (reactive text, JSXIf, JSXFor)\n if (typeof vnode === 'function') {\n if (domNode.nodeType === 3) {\n // Dev-mode: warn if text content differs\n if (__DEV__) {\n const val = vnode();\n const expected = val == null ? '' : String(val);\n if (domNode.textContent !== expected) {\n console.warn(`Tova hydration mismatch: text expected \"${expected}\" but got \"${domNode.textContent}\"`);\n }\n }\n // Reactive text: attach effect to existing text node\n domNode.__tovaReactive = true;\n createEffect(() => {\n const val = vnode();\n const text = val == null ? '' : String(val);\n if (domNode.textContent !== text) domNode.textContent = text;\n });\n return domNode.nextSibling;\n }\n // Complex dynamic block: insert marker-based render, replace SSR node\n const parent = domNode.parentNode;\n const next = domNode.nextSibling;\n const rendered = render(vnode);\n parent.replaceChild(rendered, domNode);\n return next;\n }\n\n // Primitive text — already correct from SSR\n if (typeof vnode === 'string' || typeof vnode === 'number' || typeof vnode === 'boolean') {\n if (__DEV__ && domNode.nodeType === 3) {\n const expected = String(vnode);\n if (domNode.textContent !== expected) {\n console.warn(`Tova hydration mismatch: text expected \"${expected}\" but got \"${domNode.textContent}\"`);\n }\n }\n return domNode.nextSibling;\n }\n\n // Array\n if (Array.isArray(vnode)) {\n let cursor = domNode;\n for (const child of flattenVNodes(vnode)) {\n if (!cursor) break;\n cursor = hydrateVNode(cursor, child);\n }\n return cursor;\n }\n\n if (!vnode.__tova) return domNode.nextSibling;\n\n // Fragment — children rendered inline in SSR (no wrapper)\n if (vnode.tag === '__fragment') {\n const children = flattenVNodes(vnode.children);\n let cursor = domNode;\n for (const child of children) {\n if (!cursor) break;\n cursor = hydrateVNode(cursor, child);\n }\n return cursor;\n }\n\n // Dynamic node — SSR marker-aware hydration\n if (vnode.tag === '__dynamic' && typeof vnode.compute === 'function') {\n // Check if current domNode is an SSR marker (<!--tova-s:ID-->)\n if (isSSRMarker(domNode)) {\n const { content, endMarker } = collectSSRMarkerContent(domNode);\n const parent = domNode.parentNode;\n\n // Remove SSR markers and content, replace with reactive marker\n const afterEnd = endMarker ? endMarker.nextSibling : null;\n for (const node of content) {\n if (node.parentNode === parent) parent.removeChild(node);\n }\n if (endMarker && endMarker.parentNode === parent) parent.removeChild(endMarker);\n\n const rendered = render(vnode);\n parent.replaceChild(rendered, domNode);\n return afterEnd;\n }\n\n // No SSR marker — fall back to standard behavior\n const parent = domNode.parentNode;\n const next = domNode.nextSibling;\n const rendered = render(vnode);\n parent.replaceChild(rendered, domNode);\n return next;\n }\n\n // Element — attach event handlers, reactive props, refs\n if (domNode.nodeType === 1 && domNode.tagName.toLowerCase() === vnode.tag.toLowerCase()) {\n if (__DEV__) checkHydrationMismatch(domNode, vnode);\n hydrateProps(domNode, vnode.props);\n domNode.__vnode = vnode;\n\n const children = flattenVNodes(vnode.children || []);\n let cursor = domNode.firstChild;\n for (const child of children) {\n if (!cursor) break;\n cursor = hydrateVNode(cursor, child);\n }\n return domNode.nextSibling;\n }\n\n // Tag mismatch — fall back to full render\n if (__DEV__) {\n const expectedTag = vnode.tag || '(unknown)';\n const actualTag = domNode.tagName ? domNode.tagName.toLowerCase() : `nodeType:${domNode.nodeType}`;\n console.warn(`Tova hydration mismatch: expected <${expectedTag}> but got <${actualTag}>, falling back to full render`);\n }\n const parent = domNode.parentNode;\n const next = domNode.nextSibling;\n const rendered = render(vnode);\n parent.replaceChild(rendered, domNode);\n return next;\n}\n\nfunction hydrateProps(el, props) {\n for (const [key, value] of Object.entries(props)) {\n if (key === 'ref') {\n if (typeof value === 'object' && value !== null && 'current' in value) {\n value.current = el;\n } else if (typeof value === 'function') {\n value(el);\n }\n } else if (key.startsWith('on')) {\n const eventName = key.slice(2).toLowerCase();\n el.addEventListener(eventName, value);\n if (!el.__handlers) el.__handlers = {};\n el.__handlers[eventName] = value;\n } else if (key === 'key') {\n // Skip\n } else if (typeof value === 'function' && !key.startsWith('on')) {\n createEffect(() => {\n const val = value();\n applyPropValue(el, key, val);\n });\n }\n }\n}\n\nexport function hydrate(component, container) {\n if (!container) {\n console.error('Tova: Hydration target not found');\n return;\n }\n\n const startTime = typeof performance !== 'undefined' ? performance.now() : 0;\n\n const result = createRoot(() => {\n const vnode = typeof component === 'function' ? component() : component;\n if (container.firstChild) {\n hydrateVNode(container.firstChild, vnode);\n }\n });\n\n // Dispatch hydration completion event\n const duration = typeof performance !== 'undefined' ? performance.now() - startTime : 0;\n if (typeof CustomEvent !== 'undefined' && typeof container.dispatchEvent === 'function') {\n container.dispatchEvent(new CustomEvent('tova:hydrated', { detail: { duration }, bubbles: true }));\n }\n\n if (__devtools_hooks && __devtools_hooks.onHydrate) {\n __devtools_hooks.onHydrate({ duration });\n }\n\n return result;\n}\n\nexport function mount(component, container) {\n if (!container) {\n console.error('Tova: Mount target not found');\n return;\n }\n\n const result = createRoot((dispose) => {\n const vnode = typeof component === 'function' ? component() : component;\n container.innerHTML = '';\n container.appendChild(render(vnode));\n return dispose;\n });\n\n if (__devtools_hooks && __devtools_hooks.onMount) {\n __devtools_hooks.onMount();\n }\n\n return result;\n}\n\n// ─── Progressive Hydration ──────────────────────────────────\n// Hydrate a component only when it becomes visible in the viewport.\n\nexport function hydrateWhenVisible(component, domNode, options = {}) {\n if (typeof IntersectionObserver === 'undefined') {\n // Fallback: hydrate immediately\n return hydrate(component, domNode);\n }\n\n const { rootMargin = '200px' } = options;\n let hydrated = false;\n\n const observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (entry.isIntersecting && !hydrated) {\n hydrated = true;\n observer.disconnect();\n hydrate(component, domNode);\n }\n }\n },\n { rootMargin },\n );\n\n observer.observe(domNode);\n\n return () => {\n observer.disconnect();\n };\n}\n";
|
|
4
4
|
|
|
5
5
|
export const RPC_SOURCE = "// RPC bridge — client calls to server functions are auto-routed via HTTP\n\nconst RPC_BASE = typeof window !== 'undefined'\n ? (window.__TOVA_RPC_BASE || '')\n : 'http://localhost:3000';\n\nexport async function rpc(functionName, args = []) {\n const url = `${RPC_BASE}/rpc/${functionName}`;\n\n // Convert positional args to object if needed\n let body;\n if (args.length === 1 && typeof args[0] === 'object' && !Array.isArray(args[0])) {\n body = args[0];\n } else if (args.length > 0) {\n // Send as array, server will handle positional mapping\n body = { __args: args };\n } else {\n body = {};\n }\n\n try {\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n });\n\n if (!response.ok) {\n const errorText = await response.text();\n throw new Error(`RPC call to '${functionName}' failed: ${response.status} ${errorText}`);\n }\n\n const data = await response.json();\n return data.result;\n } catch (error) {\n if (error.message.includes('RPC call')) throw error;\n throw new Error(`RPC call to '${functionName}' failed: ${error.message}`);\n }\n}\n\n// Configure RPC base URL\nexport function configureRPC(baseUrl) {\n if (typeof window !== 'undefined') {\n window.__TOVA_RPC_BASE = baseUrl;\n }\n}\n";
|
|
6
6
|
|
|
@@ -363,9 +363,9 @@ export function watch(getter, callback, options = {}) {
|
|
|
363
363
|
const effect = createEffect(() => {
|
|
364
364
|
const newValue = getter();
|
|
365
365
|
if (initialized) {
|
|
366
|
-
callback(newValue, oldValue);
|
|
366
|
+
untrack(() => callback(newValue, oldValue));
|
|
367
367
|
} else if (options.immediate) {
|
|
368
|
-
callback(newValue, undefined);
|
|
368
|
+
untrack(() => callback(newValue, undefined));
|
|
369
369
|
}
|
|
370
370
|
oldValue = newValue;
|
|
371
371
|
initialized = true;
|
|
@@ -463,13 +463,9 @@ export function ErrorBoundary({ fallback, children, onError, onReset, retry = 0
|
|
|
463
463
|
if (onError) onError({ error: e, componentStack: stack });
|
|
464
464
|
}
|
|
465
465
|
|
|
466
|
-
pushErrorHandler(handleError);
|
|
467
|
-
|
|
468
466
|
// Return a reactive wrapper that switches between children and fallback
|
|
469
467
|
const childContent = children && children.length === 1 ? children[0] : tova_fragment(children || []);
|
|
470
468
|
|
|
471
|
-
popErrorHandler();
|
|
472
|
-
|
|
473
469
|
const vnode = {
|
|
474
470
|
__tova: true,
|
|
475
471
|
tag: '__dynamic',
|
|
@@ -477,6 +473,7 @@ export function ErrorBoundary({ fallback, children, onError, onReset, retry = 0
|
|
|
477
473
|
children: [],
|
|
478
474
|
_fallback: fallback,
|
|
479
475
|
_componentName: 'ErrorBoundary',
|
|
476
|
+
_errorHandler: handleError, // Active during __dynamic effect render cycle
|
|
480
477
|
compute: () => {
|
|
481
478
|
const err = error();
|
|
482
479
|
if (err) {
|
|
@@ -547,6 +544,7 @@ export function Portal({ target, children }) {
|
|
|
547
544
|
|
|
548
545
|
export function lazy(loader) {
|
|
549
546
|
let resolved = null;
|
|
547
|
+
let loadError = null;
|
|
550
548
|
let promise = null;
|
|
551
549
|
|
|
552
550
|
return function LazyWrapper(props) {
|
|
@@ -554,28 +552,28 @@ export function lazy(loader) {
|
|
|
554
552
|
return resolved(props);
|
|
555
553
|
}
|
|
556
554
|
|
|
557
|
-
const [comp, setComp] = createSignal(null);
|
|
558
|
-
const [err, setErr] = createSignal(null);
|
|
559
|
-
|
|
560
555
|
if (!promise) {
|
|
561
556
|
promise = loader()
|
|
562
557
|
.then(mod => {
|
|
563
558
|
resolved = mod.default || mod;
|
|
564
|
-
setComp(() => resolved);
|
|
565
559
|
})
|
|
566
|
-
.catch(e =>
|
|
560
|
+
.catch(e => { loadError = e; });
|
|
567
561
|
}
|
|
568
562
|
|
|
563
|
+
const [tick, setTick] = createSignal(0);
|
|
564
|
+
|
|
565
|
+
// Trigger re-render when promise settles
|
|
566
|
+
promise.then(() => setTick(1)).catch(() => setTick(1));
|
|
567
|
+
|
|
569
568
|
return {
|
|
570
569
|
__tova: true,
|
|
571
570
|
tag: '__dynamic',
|
|
572
571
|
props: {},
|
|
573
572
|
children: [],
|
|
574
573
|
compute: () => {
|
|
575
|
-
|
|
576
|
-
if (
|
|
577
|
-
|
|
578
|
-
if (c) return c(props);
|
|
574
|
+
tick(); // Track for reactivity
|
|
575
|
+
if (loadError) return tova_el('span', { className: 'tova-error' }, [String(loadError)]);
|
|
576
|
+
if (resolved) return resolved(props);
|
|
579
577
|
// Fallback while loading
|
|
580
578
|
return props && props.fallback ? props.fallback : null;
|
|
581
579
|
},
|
|
@@ -861,22 +859,36 @@ export function render(vnode) {
|
|
|
861
859
|
frag.appendChild(marker);
|
|
862
860
|
|
|
863
861
|
let prevDispose = null;
|
|
862
|
+
const errHandler = vnode._errorHandler || null;
|
|
864
863
|
createEffect(() => {
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
864
|
+
if (errHandler) pushErrorHandler(errHandler);
|
|
865
|
+
try {
|
|
866
|
+
const inner = vnode.compute();
|
|
867
|
+
const parent = marker.parentNode;
|
|
868
|
+
const ref = nextSiblingAfterMarker(marker);
|
|
869
|
+
|
|
870
|
+
if (prevDispose) {
|
|
871
|
+
prevDispose();
|
|
872
|
+
prevDispose = null;
|
|
873
|
+
}
|
|
874
|
+
clearMarkerContent(marker);
|
|
868
875
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
876
|
+
createRoot((dispose) => {
|
|
877
|
+
prevDispose = dispose;
|
|
878
|
+
const rendered = render(inner);
|
|
879
|
+
marker.__tovaNodes = insertRendered(parent, rendered, ref, marker);
|
|
880
|
+
});
|
|
881
|
+
} catch (e) {
|
|
882
|
+
if (errHandler) {
|
|
883
|
+
errHandler(e);
|
|
884
|
+
} else if (currentErrorHandler) {
|
|
885
|
+
currentErrorHandler(e);
|
|
886
|
+
} else {
|
|
887
|
+
console.error('Uncaught error during render:', e);
|
|
888
|
+
}
|
|
889
|
+
} finally {
|
|
890
|
+
if (errHandler) popErrorHandler();
|
|
872
891
|
}
|
|
873
|
-
clearMarkerContent(marker);
|
|
874
|
-
|
|
875
|
-
createRoot((dispose) => {
|
|
876
|
-
prevDispose = dispose;
|
|
877
|
-
const rendered = render(inner);
|
|
878
|
-
marker.__tovaNodes = insertRendered(parent, rendered, ref, marker);
|
|
879
|
-
});
|
|
880
892
|
});
|
|
881
893
|
|
|
882
894
|
return frag;
|
|
@@ -965,6 +977,14 @@ function applyPropValue(el, key, val) {
|
|
|
965
977
|
} else if (key === 'disabled' || key === 'readOnly' || key === 'hidden') {
|
|
966
978
|
el[key] = !!val;
|
|
967
979
|
} else if (key === 'style' && typeof val === 'object') {
|
|
980
|
+
// Clear old properties not present in new style object
|
|
981
|
+
for (let i = el.style.length - 1; i >= 0; i--) {
|
|
982
|
+
const prop = el.style[i];
|
|
983
|
+
const camel = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
984
|
+
if (!(prop in val) && !(camel in val)) {
|
|
985
|
+
el.style.removeProperty(prop);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
968
988
|
Object.assign(el.style, val);
|
|
969
989
|
} else {
|
|
970
990
|
const s = val == null ? '' : String(val);
|
|
@@ -1150,11 +1170,7 @@ function patchPositionalInMarker(marker, newChildren) {
|
|
|
1150
1170
|
if (oldNodes[i].parentNode === parent) parent.removeChild(oldNodes[i]);
|
|
1151
1171
|
}
|
|
1152
1172
|
|
|
1153
|
-
marker.__tovaNodes = oldNodes.slice(0,
|
|
1154
|
-
// Simplify: rebuild __tovaNodes from what should remain
|
|
1155
|
-
if (newCount <= oldCount) {
|
|
1156
|
-
marker.__tovaNodes = oldNodes.slice(0, newCount);
|
|
1157
|
-
}
|
|
1173
|
+
marker.__tovaNodes = oldNodes.slice(0, newCount);
|
|
1158
1174
|
}
|
|
1159
1175
|
|
|
1160
1176
|
// Keyed reconciliation for children of an element (not marker-based)
|
package/src/stdlib/inline.js
CHANGED
|
@@ -25,8 +25,8 @@ export const BUILTIN_FUNCTIONS = {
|
|
|
25
25
|
sorted: `function sorted(a, k) { const c = [...a]; if (k) c.sort((x, y) => { const kx = k(x), ky = k(y); return kx < ky ? -1 : kx > ky ? 1 : 0; }); else c.sort((x, y) => x < y ? -1 : x > y ? 1 : 0); return c; }`,
|
|
26
26
|
reversed: `function reversed(a) { return [...a].reverse(); }`,
|
|
27
27
|
zip: `function zip(...as) { if (as.length === 0) return []; const m = Math.min(...as.map(a => a.length)); const r = []; for (let i = 0; i < m; i++) r.push(as.map(a => a[i])); return r; }`,
|
|
28
|
-
min: `function min(a) {
|
|
29
|
-
max: `function max(a) {
|
|
28
|
+
min: `function min(a) { if (a.length === 0) return null; let m = a[0]; for (let i = 1; i < a.length; i++) if (a[i] < m) m = a[i]; return m; }`,
|
|
29
|
+
max: `function max(a) { if (a.length === 0) return null; let m = a[0]; for (let i = 1; i < a.length; i++) if (a[i] > m) m = a[i]; return m; }`,
|
|
30
30
|
type_of: `function type_of(v) { if (v === null) return 'Nil'; if (Array.isArray(v)) return 'List'; if (v?.__tag) return v.__tag; const t = typeof v; switch(t) { case 'number': return Number.isInteger(v) ? 'Int' : 'Float'; case 'string': return 'String'; case 'boolean': return 'Bool'; case 'function': return 'Function'; case 'object': return 'Object'; default: return 'Unknown'; } }`,
|
|
31
31
|
filter: `function filter(arr, fn) { return arr.filter(fn); }`,
|
|
32
32
|
map: `function map(arr, fn) { return arr.map(fn); }`,
|
|
@@ -163,12 +163,12 @@ Table.prototype = { get rows() { return this._rows.length; }, get columns() { re
|
|
|
163
163
|
agg_count: `function agg_count(fn) { if (!fn) return (rows) => rows.length; return (rows) => rows.filter(fn).length; }`,
|
|
164
164
|
agg_mean: `function agg_mean(fn) { return (rows) => { if (rows.length === 0) return 0; return rows.reduce((a, r) => a + (typeof fn === 'function' ? fn(r) : r[fn]), 0) / rows.length; }; }`,
|
|
165
165
|
agg_median: `function agg_median(fn) { return (rows) => { if (rows.length === 0) return 0; const vs = rows.map(r => typeof fn === 'function' ? fn(r) : r[fn]).sort((a, b) => a - b); const m = Math.floor(vs.length / 2); return vs.length % 2 !== 0 ? vs[m] : (vs[m - 1] + vs[m]) / 2; }; }`,
|
|
166
|
-
agg_min: `function agg_min(fn) { return (rows) => rows.length === 0
|
|
167
|
-
agg_max: `function agg_max(fn) { return (rows) => rows.length === 0
|
|
166
|
+
agg_min: `function agg_min(fn) { return (rows) => { if (rows.length === 0) return null; let m = typeof fn === 'function' ? fn(rows[0]) : rows[0][fn]; for (let i = 1; i < rows.length; i++) { const v = typeof fn === 'function' ? fn(rows[i]) : rows[i][fn]; if (v < m) m = v; } return m; }; }`,
|
|
167
|
+
agg_max: `function agg_max(fn) { return (rows) => { if (rows.length === 0) return null; let m = typeof fn === 'function' ? fn(rows[0]) : rows[0][fn]; for (let i = 1; i < rows.length; i++) { const v = typeof fn === 'function' ? fn(rows[i]) : rows[i][fn]; if (v > m) m = v; } return m; }; }`,
|
|
168
168
|
|
|
169
169
|
// ── Data exploration ────────────────────────────────
|
|
170
170
|
peek: `function peek(table, opts) { const o = typeof opts === 'object' ? opts : {}; console.log(table._format ? table._format(o.n || 10, o.title) : String(table)); return table; }`,
|
|
171
|
-
describe: `function describe(table) { const stats = []; for (const col of table._columns) { const vals = table._rows.map(r => r[col]).filter(v => v != null); const st = { Column: col, Type: 'Unknown', 'Non-Null': vals.length }; if (vals.length > 0) { const s = vals[0]; if (typeof s === 'number') { st.Type = Number.isInteger(s) ? 'Int' : 'Float'; st.Mean = vals.reduce((a, b) => a + b, 0) / vals.length;
|
|
171
|
+
describe: `function describe(table) { const stats = []; for (const col of table._columns) { const vals = table._rows.map(r => r[col]).filter(v => v != null); const st = { Column: col, Type: 'Unknown', 'Non-Null': vals.length }; if (vals.length > 0) { const s = vals[0]; if (typeof s === 'number') { st.Type = Number.isInteger(s) ? 'Int' : 'Float'; st.Mean = vals.reduce((a, b) => a + b, 0) / vals.length; let mn = vals[0], mx = vals[0]; for (let i = 1; i < vals.length; i++) { if (vals[i] < mn) mn = vals[i]; if (vals[i] > mx) mx = vals[i]; } st.Min = mn; st.Max = mx; } else if (typeof s === 'string') { st.Type = 'String'; st.Unique = new Set(vals).size; } else if (typeof s === 'boolean') { st.Type = 'Bool'; } } stats.push(st); } const dt = Table(stats); console.log(dt._format(100, 'describe()')); return dt; }`,
|
|
172
172
|
schema_of: `function schema_of(table) { const sc = {}; if (table._rows.length === 0) { for (const c of table._columns) sc[c] = 'Unknown'; } else { const s = table._rows[0]; for (const c of table._columns) { const v = s[c]; if (v == null) sc[c] = 'Nil'; else if (typeof v === 'number') sc[c] = Number.isInteger(v) ? 'Int' : 'Float'; else if (typeof v === 'string') sc[c] = 'String'; else if (typeof v === 'boolean') sc[c] = 'Bool'; else if (Array.isArray(v)) sc[c] = 'Array'; else sc[c] = 'Object'; } } console.log('Schema:'); for (const [c, t] of Object.entries(sc)) console.log(' ' + c + ': ' + t); return sc; }`,
|
|
173
173
|
|
|
174
174
|
// ── Data cleaning ───────────────────────────────────
|
|
@@ -302,7 +302,7 @@ Table.prototype = { get rows() { return this._rows.length; }, get columns() { re
|
|
|
302
302
|
// ── Math (new) ─────────────────────────────────────────
|
|
303
303
|
hypot: `function hypot(a, b) { return Math.hypot(a, b); }`,
|
|
304
304
|
lerp: `function lerp(a, b, t) { return a + (b - a) * t; }`,
|
|
305
|
-
divmod: `function divmod(a, b) {
|
|
305
|
+
divmod: `function divmod(a, b) { const q = Math.floor(a / b); return [q, a - q * b]; }`,
|
|
306
306
|
avg: `function avg(arr) { return arr.length === 0 ? 0 : arr.reduce((a, b) => a + b, 0) / arr.length; }`,
|
|
307
307
|
|
|
308
308
|
// ── Date/Time (new) ────────────────────────────────────
|
|
@@ -374,7 +374,7 @@ Table.prototype = { get rows() { return this._rows.length; }, get columns() { re
|
|
|
374
374
|
combinations: `function combinations(arr, r) { const result = []; const combo = []; function gen(start, depth) { if (depth === r) { result.push([...combo]); return; } for (let i = start; i < arr.length; i++) { combo.push(arr[i]); gen(i + 1, depth + 1); combo.pop(); } } gen(0, 0); return result; }`,
|
|
375
375
|
permutations: `function permutations(arr, r) { const n = r === undefined ? arr.length : r; const result = []; const perm = []; const used = new Array(arr.length).fill(false); function gen() { if (perm.length === n) { result.push([...perm]); return; } for (let i = 0; i < arr.length; i++) { if (!used[i]) { used[i] = true; perm.push(arr[i]); gen(); perm.pop(); used[i] = false; } } } gen(); return result; }`,
|
|
376
376
|
intersperse: `function intersperse(arr, sep) { if (arr.length <= 1) return [...arr]; const r = [arr[0]]; for (let i = 1; i < arr.length; i++) { r.push(sep, arr[i]); } return r; }`,
|
|
377
|
-
interleave: `function interleave(...arrs) { const m = Math.max(...arrs.map(a => a.length)); const r = []; for (let i = 0; i < m; i++) { for (const a of arrs) { if (i < a.length) r.push(a[i]); } } return r; }`,
|
|
377
|
+
interleave: `function interleave(...arrs) { if (arrs.length === 0) return []; const m = Math.max(...arrs.map(a => a.length)); const r = []; for (let i = 0; i < m; i++) { for (const a of arrs) { if (i < a.length) r.push(a[i]); } } return r; }`,
|
|
378
378
|
repeat_value: `function repeat_value(val, n) { return Array(n).fill(val); }`,
|
|
379
379
|
|
|
380
380
|
// ── Array Utilities ────────────────────────────────────
|
package/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated by scripts/embed-runtime.js — do not edit
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.3.0";
|