roqa 0.0.3 → 0.0.5

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,26 @@ 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.5] - 2026-03-31
9
+
10
+ ### Fixed
11
+
12
+ - Fixed a compiler bug where cleanup-captured `bind()` calls inside `<For>` and `<Show>` blocks could be fully inlined to ref assignments while leaving dangling `_cleanup_N()` calls in generated cleanup functions
13
+ - The inliner now tracks cleanup variable names for removable `bind()` subscriptions and removes stale cleanup calls when their underlying subscription has been optimized away
14
+ - Cleanup properties are omitted entirely when all generated cleanup calls were eliminated during inlining
15
+
16
+ ### Changed
17
+
18
+ - Restored the js benchmark example to the faster per-row selection strategy so compiled output uses direct row refs instead of per-row subscriptions to a shared selection cell
19
+
20
+ ## [0.0.4] - 2026-01-10
21
+
22
+ ### Fixed
23
+
24
+ - Fixed `set()` calls not notifying subscribers when using cleanup-captured `bind()` calls inside `<For>` and `<Show>` blocks
25
+ - The `findBindCallbacks` function in the inliner now correctly detects `bind()` calls in variable declarations (`const _cleanup_N = bind(...)`) in addition to expression statements
26
+ - This ensures the effect loop is generated for cells with non-inlined bind callbacks
27
+
8
28
  ## [0.0.3] - 2026-01-10
9
29
 
10
30
  ### Fixed
package/package.json CHANGED
@@ -1,77 +1,77 @@
1
1
  {
2
- "name": "roqa",
3
- "version": "0.0.3",
4
- "description": "Roqa is a reactive UI framework",
5
- "keywords": [
6
- "UI",
7
- "framework",
8
- "roqa",
9
- "roqajs",
10
- "web components",
11
- "custom elements",
12
- "jsx",
13
- "compiler"
14
- ],
15
- "homepage": "https://roqa.dev",
16
- "bugs": {
17
- "url": "https://github.com/roqajs/roqa/issues"
18
- },
19
- "license": "MIT",
20
- "author": "Hawk Ticehurst",
21
- "sideEffects": false,
22
- "repository": {
23
- "type": "git",
24
- "url": "git+https://github.com/roqajs/roqa.git",
25
- "directory": "packages/roqa"
26
- },
27
- "type": "module",
28
- "files": [
29
- "src",
30
- "types",
31
- "README.md",
32
- "LICENSE",
33
- "CHANGELOG.md"
34
- ],
35
- "exports": {
36
- ".": {
37
- "types": "./types/index.d.ts",
38
- "import": "./src/runtime/index.js",
39
- "browser": "./src/runtime/index.js",
40
- "default": "./src/runtime/index.js"
41
- },
42
- "./package.json": "./package.json",
43
- "./compiler": {
44
- "types": "./types/compiler.d.ts",
45
- "import": "./src/compiler/index.js",
46
- "default": "./src/compiler/index.js"
47
- },
48
- "./jsx-runtime": {
49
- "types": "./src/jsx-runtime.d.ts",
50
- "import": "./src/jsx-runtime.js",
51
- "default": "./src/jsx-runtime.js"
52
- }
53
- },
54
- "dependencies": {
55
- "@babel/generator": "^7.28.5",
56
- "@babel/parser": "^7.28.5",
57
- "@babel/traverse": "^7.28.5",
58
- "@babel/types": "^7.28.5",
59
- "magic-string": "^0.30.21"
60
- },
61
- "devDependencies": {
62
- "@vitest/browser": "^3.0.0",
63
- "@vitest/coverage-v8": "^3.0.0",
64
- "playwright": "^1.49.0",
65
- "vitest": "^3.0.0"
66
- },
67
- "engines": {
68
- "node": ">=20.0.0"
69
- },
70
- "scripts": {
71
- "test": "vitest run",
72
- "test:watch": "vitest",
73
- "test:unit": "vitest run --project unit",
74
- "test:browser": "vitest run --project browser",
75
- "test:coverage": "vitest run --coverage"
76
- }
77
- }
2
+ "name": "roqa",
3
+ "version": "0.0.5",
4
+ "description": "Roqa is a reactive UI framework",
5
+ "keywords": [
6
+ "UI",
7
+ "framework",
8
+ "roqa",
9
+ "roqajs",
10
+ "web components",
11
+ "custom elements",
12
+ "jsx",
13
+ "compiler"
14
+ ],
15
+ "homepage": "https://roqa.dev",
16
+ "bugs": {
17
+ "url": "https://github.com/roqajs/roqa/issues"
18
+ },
19
+ "license": "MIT",
20
+ "author": "Hawk Ticehurst",
21
+ "sideEffects": false,
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/roqajs/roqa.git",
25
+ "directory": "packages/roqa"
26
+ },
27
+ "type": "module",
28
+ "files": [
29
+ "src",
30
+ "types",
31
+ "README.md",
32
+ "LICENSE",
33
+ "CHANGELOG.md"
34
+ ],
35
+ "exports": {
36
+ ".": {
37
+ "types": "./types/index.d.ts",
38
+ "import": "./src/runtime/index.js",
39
+ "browser": "./src/runtime/index.js",
40
+ "default": "./src/runtime/index.js"
41
+ },
42
+ "./package.json": "./package.json",
43
+ "./compiler": {
44
+ "types": "./types/compiler.d.ts",
45
+ "import": "./src/compiler/index.js",
46
+ "default": "./src/compiler/index.js"
47
+ },
48
+ "./jsx-runtime": {
49
+ "types": "./src/jsx-runtime.d.ts",
50
+ "import": "./src/jsx-runtime.js",
51
+ "default": "./src/jsx-runtime.js"
52
+ }
53
+ },
54
+ "scripts": {
55
+ "test": "vitest run",
56
+ "test:watch": "vitest",
57
+ "test:unit": "vitest run --project unit",
58
+ "test:browser": "vitest run --project browser",
59
+ "test:coverage": "vitest run --coverage"
60
+ },
61
+ "dependencies": {
62
+ "@babel/generator": "catalog:default",
63
+ "@babel/parser": "catalog:default",
64
+ "@babel/traverse": "catalog:default",
65
+ "@babel/types": "catalog:default",
66
+ "magic-string": "catalog:default"
67
+ },
68
+ "devDependencies": {
69
+ "@vitest/browser": "^3.0.0",
70
+ "@vitest/coverage-v8": "^3.0.0",
71
+ "playwright": "^1.49.0",
72
+ "vitest": "^3.0.0"
73
+ },
74
+ "engines": {
75
+ "node": ">=20.0.0"
76
+ }
77
+ }
@@ -458,66 +458,90 @@ function findBlockInfo(ast, code) {
458
458
  * Returns a map from cell code -> array of callback info
459
459
  */
460
460
  function findBindCallbacks(ast, code) {
461
- // Map: cellCode -> [{ callbackBody, elementVars, refNum, paramName, statementStart, statementEnd, hasClosureVars }]
461
+ // Map: cellCode -> [{ callbackBody, elementVars, refNum, paramName, statementStart, statementEnd, hasClosureVars, cleanupVarName }]
462
462
  const bindCallbacks = new Map();
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, cleanupVarName = null) {
473
+ const cellArg = bindExpr.arguments[0];
474
+ const callbackArg = bindExpr.arguments[1];
475
+ if (!cellArg || !callbackArg) return;
470
476
 
471
- const cellArg = expr.arguments[0];
472
- const callbackArg = expr.arguments[1];
473
- if (!cellArg || !callbackArg) return;
477
+ // Get callback info
478
+ if (
479
+ callbackArg.type !== "ArrowFunctionExpression" &&
480
+ callbackArg.type !== "FunctionExpression"
481
+ ) {
482
+ return;
483
+ }
474
484
 
475
- // Get callback info
476
- if (
477
- callbackArg.type !== "ArrowFunctionExpression" &&
478
- callbackArg.type !== "FunctionExpression"
479
- ) {
480
- return;
481
- }
485
+ const cellCode = code.slice(cellArg.start, cellArg.end);
486
+ const paramName = callbackArg.params[0]?.name || "v";
482
487
 
483
- const cellCode = code.slice(cellArg.start, cellArg.end);
484
- const paramName = callbackArg.params[0]?.name || "v";
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
+ }
485
498
 
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
- }
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;
496
503
 
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;
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);
501
508
 
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);
509
+ // Store bind callback info
510
+ if (!bindCallbacks.has(cellCode)) {
511
+ bindCallbacks.set(cellCode, []);
512
+ }
506
513
 
507
- // Store bind callback info
508
- if (!bindCallbacks.has(cellCode)) {
509
- bindCallbacks.set(cellCode, []);
510
- }
514
+ bindCallbacks.get(cellCode).push({
515
+ callbackBody: bodyCode,
516
+ elementVars,
517
+ refNum,
518
+ paramName,
519
+ statementStart: stmtStart,
520
+ statementEnd: stmtEnd,
521
+ hasClosureVars,
522
+ cleanupVarName,
523
+ });
524
+ }
511
525
 
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
- });
526
+ traverse(ast, {
527
+ // Handle: bind(cell, callback);
528
+ ExpressionStatement(path) {
529
+ const expr = path.node.expression;
530
+ if (!isBindCall(expr)) return;
531
+ processBindCall(expr, path.node.start, path.node.end);
532
+ },
533
+ // Handle: const _cleanup_N = bind(cell, callback);
534
+ VariableDeclaration(path) {
535
+ for (const decl of path.node.declarations) {
536
+ if (decl.init && isBindCall(decl.init)) {
537
+ processBindCall(
538
+ decl.init,
539
+ path.node.start,
540
+ path.node.end,
541
+ decl.id.type === "Identifier" ? decl.id.name : null,
542
+ );
543
+ }
544
+ }
521
545
  },
522
546
  noScope: true,
523
547
  });
@@ -525,6 +549,35 @@ function findBindCallbacks(ast, code) {
525
549
  return bindCallbacks;
526
550
  }
527
551
 
552
+ function isCleanupCallStatement(node, cleanupVarNames) {
553
+ return (
554
+ node?.type === "ExpressionStatement" &&
555
+ node.expression?.type === "CallExpression" &&
556
+ node.expression.callee?.type === "Identifier" &&
557
+ cleanupVarNames.has(node.expression.callee.name) &&
558
+ node.expression.arguments.length === 0
559
+ );
560
+ }
561
+
562
+ function removeObjectProperty(s, code, propertyNode) {
563
+ let start = propertyNode.start;
564
+ let end = propertyNode.end;
565
+
566
+ let left = start - 1;
567
+ while (left >= 0 && /\s/.test(code[left])) left--;
568
+ if (left >= 0 && code[left] === ",") {
569
+ start = left;
570
+ } else {
571
+ let right = end;
572
+ while (right < code.length && /\s/.test(code[right])) right++;
573
+ if (right < code.length && code[right] === ",") {
574
+ end = right + 1;
575
+ }
576
+ }
577
+
578
+ s.remove(start, end);
579
+ }
580
+
528
581
  /**
529
582
  * Find element variables used in a callback body
530
583
  * Looks for element.property = ... patterns
@@ -722,6 +775,7 @@ export function inlineGetCalls(code, filename) {
722
775
 
723
776
  // Track bind statements to remove
724
777
  const bindStatementsToRemove = [];
778
+ const inlinedCleanupVars = new Set();
725
779
 
726
780
  // Track roqa imports for removal
727
781
  const importsToRemove = [];
@@ -1090,6 +1144,9 @@ export function inlineGetCalls(code, filename) {
1090
1144
 
1091
1145
  // Replace bind() call with ref assignment(s)
1092
1146
  if (refAssignment) {
1147
+ if (callback.cleanupVarName) {
1148
+ inlinedCleanupVars.add(callback.cleanupVarName);
1149
+ }
1093
1150
  s.overwrite(start, end, refAssignment);
1094
1151
  } else {
1095
1152
  // No element vars found - bind() can't be fully inlined
@@ -1099,6 +1156,60 @@ export function inlineGetCalls(code, filename) {
1099
1156
  }
1100
1157
  }
1101
1158
 
1159
+ // Remove cleanup calls for bind() subscriptions that were fully inlined away.
1160
+ // If a cleanup block only contains now-removed cleanup calls, drop the cleanup
1161
+ // property entirely so runtime list items don't carry no-op cleanup functions.
1162
+ if (inlinedCleanupVars.size > 0) {
1163
+ const cleanupStatementsToRemove = [];
1164
+ const cleanupPropertiesToRemove = [];
1165
+
1166
+ traverse(ast, {
1167
+ ObjectProperty(path) {
1168
+ const node = path.node;
1169
+ const key = node.key;
1170
+ const isCleanupProperty =
1171
+ (key?.type === "Identifier" && key.name === "cleanup") ||
1172
+ (key?.type === "StringLiteral" && key.value === "cleanup");
1173
+ if (!isCleanupProperty) return;
1174
+
1175
+ const fn = node.value;
1176
+ if (
1177
+ !fn ||
1178
+ (fn.type !== "ArrowFunctionExpression" && fn.type !== "FunctionExpression") ||
1179
+ fn.body?.type !== "BlockStatement"
1180
+ ) {
1181
+ return;
1182
+ }
1183
+
1184
+ const statements = fn.body.body;
1185
+ if (statements.length === 0) return;
1186
+
1187
+ const removableStatements = statements.filter((stmt) =>
1188
+ isCleanupCallStatement(stmt, inlinedCleanupVars),
1189
+ );
1190
+ if (removableStatements.length === 0) return;
1191
+
1192
+ if (removableStatements.length === statements.length) {
1193
+ cleanupPropertiesToRemove.push(node);
1194
+ return;
1195
+ }
1196
+
1197
+ cleanupStatementsToRemove.push(...removableStatements);
1198
+ },
1199
+ noScope: true,
1200
+ });
1201
+
1202
+ cleanupStatementsToRemove.sort((a, b) => b.start - a.start);
1203
+ for (const stmt of cleanupStatementsToRemove) {
1204
+ s.remove(stmt.start, stmt.end);
1205
+ }
1206
+
1207
+ cleanupPropertiesToRemove.sort((a, b) => b.start - a.start);
1208
+ for (const property of cleanupPropertiesToRemove) {
1209
+ removeObjectProperty(s, code, property);
1210
+ }
1211
+ }
1212
+
1102
1213
  // Remove imports that are no longer needed
1103
1214
  // Collect all imports to remove first
1104
1215
  // Only remove bind import if ALL bind calls were fully inlined (had element vars and no closure vars)
@@ -66,10 +66,13 @@ function lisAlgorithm(arr) {
66
66
  * @param {*} value - The data item
67
67
  * @param {number} index - Array index
68
68
  * @param {Function} renderFn - (anchor, value, index) => { start, end } or just appends nodes
69
+ * @param {Object} forState - The for loop state (to track cleanup count)
69
70
  */
70
- function createItem(anchor, value, index, renderFn) {
71
+ function createItem(anchor, value, index, renderFn, forState) {
71
72
  // renderFn should return { start, end } nodes for the item
72
73
  const item = renderFn(anchor, value, index);
74
+ // Track if this item has cleanup for fast-path optimization
75
+ if (item.cleanup) forState.cleanupCount++;
73
76
  return {
74
77
  s: item, // state: { start, end } - the DOM range for this item
75
78
  v: value,
@@ -101,14 +104,18 @@ function moveItem(item, anchor) {
101
104
 
102
105
  /**
103
106
  * Destroy an item's DOM nodes and run cleanup if present
107
+ * @param {Object} forState - The for loop state (to track cleanup count)
104
108
  */
105
- function destroyItem(item) {
109
+ function destroyItem(item, forState) {
106
110
  const state = item.s;
107
111
  let node = state.start;
108
112
  const end = state.end;
109
113
 
110
114
  // Run cleanup function if the render provided one
111
- if (state.cleanup) state.cleanup();
115
+ if (state.cleanup) {
116
+ state.cleanup();
117
+ forState.cleanupCount--;
118
+ }
112
119
 
113
120
  while (node !== null) {
114
121
  const next = node.nextSibling;
@@ -122,11 +129,14 @@ function destroyItem(item) {
122
129
  * Fast path: clear all items when going from non-empty to empty
123
130
  */
124
131
  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();
132
+ // Only run cleanup loop if there are items with cleanup functions
133
+ if (forState.cleanupCount > 0) {
134
+ const items = forState.items;
135
+ for (let i = 0; i < items.length; i++) {
136
+ const state = items[i].s;
137
+ if (state.cleanup) state.cleanup();
138
+ }
139
+ forState.cleanupCount = 0;
130
140
  }
131
141
 
132
142
  const parent_node = anchor.parentNode;
@@ -161,7 +171,7 @@ function reconcileByRef(anchor, forState, b, renderFn) {
161
171
  // Empty -> non-empty: create all
162
172
  if (aLen === 0) {
163
173
  for (; j < bLen; j++) {
164
- bItems[j] = createItem(anchor, b[j], j, renderFn);
174
+ bItems[j] = createItem(anchor, b[j], j, renderFn, forState);
165
175
  }
166
176
  forState.array = b;
167
177
  forState.items = bItems;
@@ -205,14 +215,14 @@ function reconcileByRef(anchor, forState, b, renderFn) {
205
215
  while (j <= bEnd) {
206
216
  bVal = b[j];
207
217
  target = j >= aLen ? anchor : aItems[j].s.start;
208
- bItems[j] = createItem(target, bVal, j, renderFn);
218
+ bItems[j] = createItem(target, bVal, j, renderFn, forState);
209
219
  j++;
210
220
  }
211
221
  }
212
222
  } else if (j > bEnd) {
213
223
  // Only removals
214
224
  while (j <= aEnd) {
215
- destroyItem(aItems[j++]);
225
+ destroyItem(aItems[j++], forState);
216
226
  }
217
227
  } else {
218
228
  // General case: need full reconciliation
@@ -237,7 +247,7 @@ function reconcileByRef(anchor, forState, b, renderFn) {
237
247
  sources[j - bStart] = i + 1;
238
248
  if (fastPathRemoval) {
239
249
  fastPathRemoval = false;
240
- while (aStart < i) destroyItem(aItems[aStart++]);
250
+ while (aStart < i) destroyItem(aItems[aStart++], forState);
241
251
  }
242
252
  if (pos > j) moved = true;
243
253
  else pos = j;
@@ -246,9 +256,9 @@ function reconcileByRef(anchor, forState, b, renderFn) {
246
256
  break;
247
257
  }
248
258
  }
249
- if (!fastPathRemoval && j > bEnd) destroyItem(aItems[i]);
259
+ if (!fastPathRemoval && j > bEnd) destroyItem(aItems[i], forState);
250
260
  } else if (!fastPathRemoval) {
251
- destroyItem(aItems[i]);
261
+ destroyItem(aItems[i], forState);
252
262
  }
253
263
  }
254
264
  } else {
@@ -262,7 +272,7 @@ function reconcileByRef(anchor, forState, b, renderFn) {
262
272
  if (j !== undefined) {
263
273
  if (fastPathRemoval) {
264
274
  fastPathRemoval = false;
265
- while (i > aStart) destroyItem(aItems[aStart++]);
275
+ while (i > aStart) destroyItem(aItems[aStart++], forState);
266
276
  }
267
277
  sources[j - bStart] = i + 1;
268
278
  if (pos > j) moved = true;
@@ -270,10 +280,10 @@ function reconcileByRef(anchor, forState, b, renderFn) {
270
280
  bItems[j] = aItems[i];
271
281
  ++patched;
272
282
  } else if (!fastPathRemoval) {
273
- destroyItem(aItems[i]);
283
+ destroyItem(aItems[i], forState);
274
284
  }
275
285
  } else if (!fastPathRemoval) {
276
- destroyItem(aItems[i]);
286
+ destroyItem(aItems[i], forState);
277
287
  }
278
288
  }
279
289
  }
@@ -295,7 +305,7 @@ function reconcileByRef(anchor, forState, b, renderFn) {
295
305
 
296
306
  if (sources[i] === 0) {
297
307
  bVal = b[pos];
298
- bItems[pos] = createItem(target, bVal, pos, renderFn);
308
+ bItems[pos] = createItem(target, bVal, pos, renderFn, forState);
299
309
  } else if (j < 0 || i !== seq[j]) {
300
310
  moveItem(bItems[pos], target);
301
311
  } else {
@@ -309,7 +319,7 @@ function reconcileByRef(anchor, forState, b, renderFn) {
309
319
  bVal = b[pos];
310
320
  const nextPos = pos + 1;
311
321
  target = nextPos < bLen ? bItems[nextPos].s.start : anchor;
312
- bItems[pos] = createItem(target, bVal, pos, renderFn);
322
+ bItems[pos] = createItem(target, bVal, pos, renderFn, forState);
313
323
  }
314
324
  }
315
325
  }
@@ -339,6 +349,7 @@ export function forBlock(container, sourceCell, renderFn) {
339
349
  const forState = {
340
350
  array: [],
341
351
  items: [],
352
+ cleanupCount: 0, // Track items with cleanup for fast-path optimization
342
353
  };
343
354
 
344
355
  const doUpdate = () => {
@@ -363,7 +374,7 @@ export function forBlock(container, sourceCell, renderFn) {
363
374
  // Destroy all current items
364
375
  const items = forState.items;
365
376
  for (let i = 0; i < items.length; i++) {
366
- destroyItem(items[i]);
377
+ destroyItem(items[i], forState);
367
378
  }
368
379
  forState.array = [];
369
380
  forState.items = [];