roqa 0.0.2 → 0.0.4

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/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.0.4] - 2026-01-10
9
+
10
+ ### Fixed
11
+
12
+ - Fixed `set()` calls not notifying subscribers when using cleanup-captured `bind()` calls inside `<For>` and `<Show>` blocks
13
+ - The `findBindCallbacks` function in the inliner now correctly detects `bind()` calls in variable declarations (`const _cleanup_N = bind(...)`) in addition to expression statements
14
+ - This ensures the effect loop is generated for cells with non-inlined bind callbacks
15
+
16
+ ## [0.0.3] - 2026-01-10
17
+
18
+ ### Fixed
19
+
20
+ - Fixed critical memory leak when using `<For>` with reactive bindings to cells outside the loop (e.g., `class={get(selected) === row.id ? "danger" : ""}`)
21
+ - Subscriptions to external cells are now properly cleaned up when items are removed or the array is cleared
22
+ - `reconcileFastClear` now calls cleanup functions before clearing DOM
23
+ - The compiler now generates cleanup functions that capture `bind()` unsubscribe calls inside `<For>` and `<Show>` blocks
24
+
8
25
  ## [0.0.2] - 2026-01-10
9
26
 
10
27
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roqa",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Roqa is a reactive UI framework",
5
5
  "keywords": [
6
6
  "UI",
@@ -701,16 +701,38 @@ function generateForBlock(
701
701
  // Track ref counts per cell for numbered refs (ref_1, ref_2, etc.)
702
702
  const cellRefCounts = new Map();
703
703
 
704
+ // Track cleanup functions needed from bind() calls
705
+ const cleanupVars = [];
706
+
704
707
  // Bindings inside for block
705
708
  for (const binding of processedBindings) {
706
- const bindCode = generateBinding(code, binding, usedImports, itemParam, true, cellRefCounts);
709
+ const { bindCode, cleanupVar } = generateBindingWithCleanup(
710
+ code,
711
+ binding,
712
+ usedImports,
713
+ itemParam,
714
+ true,
715
+ cellRefCounts,
716
+ cleanupVars.length,
717
+ );
707
718
  lines.push(` ${bindCode}`);
719
+ if (cleanupVar) {
720
+ cleanupVars.push(cleanupVar);
721
+ }
708
722
  }
709
723
 
710
- // Insert before anchor and return range
724
+ // Insert before anchor and return range (with cleanup if needed)
711
725
  lines.push("");
712
726
  lines.push(` anchor.before(${firstElementVar});`);
713
- lines.push(` return { start: ${firstElementVar}, end: ${firstElementVar} };`);
727
+
728
+ if (cleanupVars.length > 0) {
729
+ const cleanupCalls = cleanupVars.map((v) => `${v}()`).join("; ");
730
+ lines.push(
731
+ ` return { start: ${firstElementVar}, end: ${firstElementVar}, cleanup: () => { ${cleanupCalls}; } };`,
732
+ );
733
+ } else {
734
+ lines.push(` return { start: ${firstElementVar}, end: ${firstElementVar} };`);
735
+ }
714
736
  lines.push(` });`);
715
737
 
716
738
  return lines.join("\n");
@@ -861,16 +883,38 @@ function generateShowBlock(code, showBlock, nameGen, templateRegistry, usedImpor
861
883
  // Track ref counts per cell for numbered refs (ref_1, ref_2, etc.)
862
884
  const cellRefCounts = new Map();
863
885
 
886
+ // Track cleanup functions needed from bind() calls
887
+ const cleanupVars = [];
888
+
864
889
  // Bindings inside show block
865
890
  for (const binding of processedBindings) {
866
- const bindCode = generateBinding(code, binding, usedImports, null, true, cellRefCounts);
891
+ const { bindCode, cleanupVar } = generateBindingWithCleanup(
892
+ code,
893
+ binding,
894
+ usedImports,
895
+ null,
896
+ true,
897
+ cellRefCounts,
898
+ cleanupVars.length,
899
+ );
867
900
  lines.push(` ${bindCode}`);
901
+ if (cleanupVar) {
902
+ cleanupVars.push(cleanupVar);
903
+ }
868
904
  }
869
905
 
870
- // Insert before anchor and return range
906
+ // Insert before anchor and return range (with cleanup if needed)
871
907
  lines.push("");
872
908
  lines.push(` anchor.before(${firstElementVar});`);
873
- lines.push(` return { start: ${firstElementVar}, end: ${firstElementVar} };`);
909
+
910
+ if (cleanupVars.length > 0) {
911
+ const cleanupCalls = cleanupVars.map((v) => `${v}()`).join("; ");
912
+ lines.push(
913
+ ` return { start: ${firstElementVar}, end: ${firstElementVar}, cleanup: () => { ${cleanupCalls}; } };`,
914
+ );
915
+ } else {
916
+ lines.push(` return { start: ${firstElementVar}, end: ${firstElementVar} };`);
917
+ }
874
918
  lines.push(` }${depsCode});`);
875
919
 
876
920
  return lines.join("\n");
@@ -919,10 +963,83 @@ function generateBinding(
919
963
  itemParam = null,
920
964
  insideForBlock = false,
921
965
  cellRefCounts = null,
966
+ ) {
967
+ // At component level (not inside forBlock), we don't need cleanup tracking
968
+ // because cleanup is handled by the component's disconnectedCallback
969
+ const result = generateBindingCore(
970
+ code,
971
+ binding,
972
+ usedImports,
973
+ itemParam,
974
+ insideForBlock,
975
+ cellRefCounts,
976
+ false, // Don't generate cleanup variables
977
+ 0,
978
+ );
979
+ return result.bindCode;
980
+ }
981
+
982
+ /**
983
+ * Generate code for a binding, returning both the code and any cleanup variable
984
+ * Used inside forBlock/showBlock where cleanup is needed on item destruction
985
+ * @param {string} code - Original source code
986
+ * @param {object} binding - Binding info
987
+ * @param {Set} usedImports - Set of imports to track
988
+ * @param {string} itemParam - Item parameter name (for forBlock context)
989
+ * @param {boolean} insideForBlock - Whether we're inside a forBlock callback
990
+ * @param {Map} cellRefCounts - Map to track ref counts per cell (for numbered refs)
991
+ * @param {number} cleanupIndex - Index for naming cleanup variables
992
+ * @returns {{ bindCode: string, cleanupVar: string|null }}
993
+ */
994
+ function generateBindingWithCleanup(
995
+ code,
996
+ binding,
997
+ usedImports,
998
+ itemParam = null,
999
+ insideForBlock = false,
1000
+ cellRefCounts = null,
1001
+ cleanupIndex = 0,
1002
+ ) {
1003
+ return generateBindingCore(
1004
+ code,
1005
+ binding,
1006
+ usedImports,
1007
+ itemParam,
1008
+ insideForBlock,
1009
+ cellRefCounts,
1010
+ true, // Generate cleanup variables
1011
+ cleanupIndex,
1012
+ );
1013
+ }
1014
+
1015
+ /**
1016
+ * Core binding generation logic
1017
+ * @param {string} code - Original source code
1018
+ * @param {object} binding - Binding info
1019
+ * @param {Set} usedImports - Set of imports to track
1020
+ * @param {string} itemParam - Item parameter name (for forBlock context)
1021
+ * @param {boolean} insideForBlock - Whether we're inside a forBlock callback
1022
+ * @param {Map} cellRefCounts - Map to track ref counts per cell (for numbered refs)
1023
+ * @param {boolean} generateCleanup - Whether to generate cleanup variables for bind() calls
1024
+ * @param {number} cleanupIndex - Index for naming cleanup variables
1025
+ * @returns {{ bindCode: string, cleanupVar: string|null }}
1026
+ */
1027
+ function generateBindingCore(
1028
+ code,
1029
+ binding,
1030
+ usedImports,
1031
+ itemParam = null,
1032
+ insideForBlock = false,
1033
+ cellRefCounts = null,
1034
+ generateCleanup = false,
1035
+ cleanupIndex = 0,
922
1036
  ) {
923
1037
  // Handle prop bindings (for custom elements)
924
1038
  if (binding.type === "prop") {
925
- return generatePropBinding(code, binding, usedImports, insideForBlock);
1039
+ return {
1040
+ bindCode: generatePropBinding(code, binding, usedImports, insideForBlock),
1041
+ cleanupVar: null,
1042
+ };
926
1043
  }
927
1044
 
928
1045
  const {
@@ -940,7 +1057,16 @@ function generateBinding(
940
1057
 
941
1058
  // Handle new contentParts format (concatenated text content)
942
1059
  if (contentParts) {
943
- return generateContentPartsBinding(code, binding, usedImports, insideForBlock, cellRefCounts);
1060
+ return {
1061
+ bindCode: generateContentPartsBinding(
1062
+ code,
1063
+ binding,
1064
+ usedImports,
1065
+ insideForBlock,
1066
+ cellRefCounts,
1067
+ ),
1068
+ cleanupVar: null,
1069
+ };
944
1070
  }
945
1071
 
946
1072
  // Build prefix string if we have static text before the dynamic expression
@@ -957,9 +1083,15 @@ function generateBinding(
957
1083
  // Static assignment
958
1084
  const exprCode = generateExpr(code, fullExpression);
959
1085
  if (needsSetAttribute) {
960
- return `${targetVar}.setAttribute("${targetProperty}", ${prefixCode}${exprCode});`;
1086
+ return {
1087
+ bindCode: `${targetVar}.setAttribute("${targetProperty}", ${prefixCode}${exprCode});`,
1088
+ cleanupVar: null,
1089
+ };
961
1090
  }
962
- return `${targetVar}.${targetProperty} = ${prefixCode}${exprCode};`;
1091
+ return {
1092
+ bindCode: `${targetVar}.${targetProperty} = ${prefixCode}${exprCode};`,
1093
+ cleanupVar: null,
1094
+ };
963
1095
  }
964
1096
 
965
1097
  // Generate the cell argument code
@@ -982,8 +1114,11 @@ function generateBinding(
982
1114
 
983
1115
  // Emit: initial value assignment + ref storage on cell
984
1116
  // cell.ref_N = element;
985
- return `${targetVar}.${targetProperty} = ${initialExprCode};
986
- ${indent}${cellCode}.${CONSTANTS.REF_PREFIX}${refNum} = ${targetVar};`;
1117
+ return {
1118
+ bindCode: `${targetVar}.${targetProperty} = ${initialExprCode};
1119
+ ${indent}${cellCode}.${CONSTANTS.REF_PREFIX}${refNum} = ${targetVar};`,
1120
+ cleanupVar: null,
1121
+ };
987
1122
  }
988
1123
 
989
1124
  // Fall back to bind() for complex bindings (or SVG attributes)
@@ -1000,18 +1135,50 @@ ${indent}${cellCode}.${CONSTANTS.REF_PREFIX}${refNum} = ${targetVar};`;
1000
1135
  bindExprCode = "v";
1001
1136
  }
1002
1137
 
1003
- // Set initial value AND bind for updates
1138
+ // Determine indentation based on context
1139
+ const indent = insideForBlock ? " " : " ";
1140
+
1141
+ // Generate with or without cleanup variable capture
1142
+ if (generateCleanup) {
1143
+ const cleanupVar = `_cleanup_${cleanupIndex}`;
1144
+
1145
+ if (needsSetAttribute) {
1146
+ return {
1147
+ bindCode: `${targetVar}.setAttribute("${targetProperty}", ${prefixCode}${initialExprCode});
1148
+ ${indent}const ${cleanupVar} = bind(${cellCode}, (v) => {
1149
+ ${indent} ${targetVar}.setAttribute("${targetProperty}", ${prefixCode}${bindExprCode});
1150
+ ${indent}});`,
1151
+ cleanupVar,
1152
+ };
1153
+ }
1154
+
1155
+ return {
1156
+ bindCode: `${targetVar}.${targetProperty} = ${prefixCode}${initialExprCode};
1157
+ ${indent}const ${cleanupVar} = bind(${cellCode}, (v) => {
1158
+ ${indent} ${targetVar}.${targetProperty} = ${prefixCode}${bindExprCode};
1159
+ ${indent}});`,
1160
+ cleanupVar,
1161
+ };
1162
+ }
1163
+
1164
+ // No cleanup needed - just generate the bind without capturing
1004
1165
  if (needsSetAttribute) {
1005
- return `${targetVar}.setAttribute("${targetProperty}", ${prefixCode}${initialExprCode});
1006
- bind(${cellCode}, (v) => {
1007
- ${targetVar}.setAttribute("${targetProperty}", ${prefixCode}${bindExprCode});
1008
- });`;
1166
+ return {
1167
+ bindCode: `${targetVar}.setAttribute("${targetProperty}", ${prefixCode}${initialExprCode});
1168
+ ${indent}bind(${cellCode}, (v) => {
1169
+ ${indent} ${targetVar}.setAttribute("${targetProperty}", ${prefixCode}${bindExprCode});
1170
+ ${indent}});`,
1171
+ cleanupVar: null,
1172
+ };
1009
1173
  }
1010
1174
 
1011
- return `${targetVar}.${targetProperty} = ${prefixCode}${initialExprCode};
1012
- bind(${cellCode}, (v) => {
1013
- ${targetVar}.${targetProperty} = ${prefixCode}${bindExprCode};
1014
- });`;
1175
+ return {
1176
+ bindCode: `${targetVar}.${targetProperty} = ${prefixCode}${initialExprCode};
1177
+ ${indent}bind(${cellCode}, (v) => {
1178
+ ${indent} ${targetVar}.${targetProperty} = ${prefixCode}${bindExprCode};
1179
+ ${indent}});`,
1180
+ cleanupVar: null,
1181
+ };
1015
1182
  }
1016
1183
 
1017
1184
  /**
@@ -463,61 +463,79 @@ function findBindCallbacks(ast, code) {
463
463
  // Track ref numbers per cell
464
464
  const refCounters = new Map();
465
465
 
466
- traverse(ast, {
467
- ExpressionStatement(path) {
468
- const expr = path.node.expression;
469
- if (!isBindCall(expr)) return;
466
+ /**
467
+ * Process a bind() call expression and add to bindCallbacks
468
+ * @param {object} bindExpr - The bind() CallExpression node
469
+ * @param {number} stmtStart - Start position of containing statement
470
+ * @param {number} stmtEnd - End position of containing statement
471
+ */
472
+ function processBindCall(bindExpr, stmtStart, stmtEnd) {
473
+ const cellArg = bindExpr.arguments[0];
474
+ const callbackArg = bindExpr.arguments[1];
475
+ if (!cellArg || !callbackArg) return;
476
+
477
+ // Get callback info
478
+ if (
479
+ callbackArg.type !== "ArrowFunctionExpression" &&
480
+ callbackArg.type !== "FunctionExpression"
481
+ ) {
482
+ return;
483
+ }
470
484
 
471
- const cellArg = expr.arguments[0];
472
- const callbackArg = expr.arguments[1];
473
- if (!cellArg || !callbackArg) return;
485
+ const cellCode = code.slice(cellArg.start, cellArg.end);
486
+ const paramName = callbackArg.params[0]?.name || "v";
474
487
 
475
- // Get callback info
476
- if (
477
- callbackArg.type !== "ArrowFunctionExpression" &&
478
- callbackArg.type !== "FunctionExpression"
479
- ) {
480
- return;
481
- }
488
+ // Get the callback body
489
+ const body = callbackArg.body;
490
+ let bodyCode;
491
+ if (body.type === "BlockStatement") {
492
+ // Extract statements from block, removing braces
493
+ bodyCode = code.slice(body.start + 1, body.end - 1).trim();
494
+ } else {
495
+ // Expression body
496
+ bodyCode = code.slice(body.start, body.end);
497
+ }
482
498
 
483
- const cellCode = code.slice(cellArg.start, cellArg.end);
484
- const paramName = callbackArg.params[0]?.name || "v";
499
+ // Find element variables used in the callback (e.g., p_1_text, tr_1)
500
+ // Also detect closure variables that would prevent inlining
501
+ const { elementVars, closureVars } = findElementVariables(body, code, paramName, cellCode);
502
+ const hasClosureVars = closureVars.size > 0;
485
503
 
486
- // Get the callback body
487
- const body = callbackArg.body;
488
- let bodyCode;
489
- if (body.type === "BlockStatement") {
490
- // Extract statements from block, removing braces
491
- bodyCode = code.slice(body.start + 1, body.end - 1).trim();
492
- } else {
493
- // Expression body
494
- bodyCode = code.slice(body.start, body.end);
495
- }
504
+ // Get or create ref number for this cell
505
+ const currentRef = refCounters.get(cellCode) || 0;
506
+ const refNum = currentRef + 1;
507
+ refCounters.set(cellCode, refNum);
496
508
 
497
- // Find element variables used in the callback (e.g., p_1_text, tr_1)
498
- // Also detect closure variables that would prevent inlining
499
- const { elementVars, closureVars } = findElementVariables(body, code, paramName, cellCode);
500
- const hasClosureVars = closureVars.size > 0;
509
+ // Store bind callback info
510
+ if (!bindCallbacks.has(cellCode)) {
511
+ bindCallbacks.set(cellCode, []);
512
+ }
501
513
 
502
- // Get or create ref number for this cell
503
- const currentRef = refCounters.get(cellCode) || 0;
504
- const refNum = currentRef + 1;
505
- refCounters.set(cellCode, refNum);
514
+ bindCallbacks.get(cellCode).push({
515
+ callbackBody: bodyCode,
516
+ elementVars,
517
+ refNum,
518
+ paramName,
519
+ statementStart: stmtStart,
520
+ statementEnd: stmtEnd,
521
+ hasClosureVars,
522
+ });
523
+ }
506
524
 
507
- // Store bind callback info
508
- if (!bindCallbacks.has(cellCode)) {
509
- bindCallbacks.set(cellCode, []);
525
+ traverse(ast, {
526
+ // Handle: bind(cell, callback);
527
+ ExpressionStatement(path) {
528
+ const expr = path.node.expression;
529
+ if (!isBindCall(expr)) return;
530
+ processBindCall(expr, path.node.start, path.node.end);
531
+ },
532
+ // Handle: const _cleanup_N = bind(cell, callback);
533
+ VariableDeclaration(path) {
534
+ for (const decl of path.node.declarations) {
535
+ if (decl.init && isBindCall(decl.init)) {
536
+ processBindCall(decl.init, path.node.start, path.node.end);
537
+ }
510
538
  }
511
-
512
- bindCallbacks.get(cellCode).push({
513
- callbackBody: bodyCode,
514
- elementVars,
515
- refNum,
516
- paramName,
517
- statementStart: path.node.start,
518
- statementEnd: path.node.end,
519
- hasClosureVars,
520
- });
521
539
  },
522
540
  noScope: true,
523
541
  });
@@ -122,6 +122,13 @@ function destroyItem(item) {
122
122
  * Fast path: clear all items when going from non-empty to empty
123
123
  */
124
124
  function reconcileFastClear(anchor, forState, array) {
125
+ // Run cleanup for all items before clearing DOM
126
+ const items = forState.items;
127
+ for (let i = 0; i < items.length; i++) {
128
+ const state = items[i].s;
129
+ if (state.cleanup) state.cleanup();
130
+ }
131
+
125
132
  const parent_node = anchor.parentNode;
126
133
  parent_node.textContent = "";
127
134
  parent_node.append(anchor);