roqa 0.0.4 → 0.0.6

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,28 @@ 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.6] - 2026-03-31
9
+
10
+ ### Added
11
+
12
+ - Added compile-time selector optimization for list selection patterns
13
+ - The compiler now detects `get(cell) === loopItem` inside `<For>` attribute expressions and generates an O(1) selector instead of N per-row `bind()` subscriptions
14
+ - A single shared `Map` (keyed by row reference) and one `bind()` on the outer cell replaces N per-row `bind()` calls
15
+ - On selection change only 2 Map callbacks fire (deselect old, select new) regardless of list size
16
+ - Row cleanup removes its Map entry to prevent memory leaks
17
+
18
+ ## [0.0.5] - 2026-03-31
19
+
20
+ ### Fixed
21
+
22
+ - 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
23
+ - The inliner now tracks cleanup variable names for removable `bind()` subscriptions and removes stale cleanup calls when their underlying subscription has been optimized away
24
+ - Cleanup properties are omitted entirely when all generated cleanup calls were eliminated during inlining
25
+
26
+ ### Changed
27
+
28
+ - 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
29
+
8
30
  ## [0.0.4] - 2026-01-10
9
31
 
10
32
  ### Fixed
package/package.json CHANGED
@@ -1,77 +1,77 @@
1
1
  {
2
- "name": "roqa",
3
- "version": "0.0.4",
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.6",
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
+ }
@@ -595,6 +595,44 @@ function generateForBlock(
595
595
 
596
596
  // Process inner bindings (these need to reference the item parameter)
597
597
  const processedBindings = processBindings(bindings, code);
598
+
599
+ // === SELECTOR DETECTION ===
600
+ // Scan for the pattern: get(outerCell) === itemParam in attribute expressions.
601
+ // When found, replace N individual bind() subscriptions with one shared dispatch
602
+ // function + a keyed Map, reducing selection change cost from O(n) to O(1).
603
+ const selectorMap = new Map(); // cellCode -> { subsVar, prevVar }
604
+ let selectorCounter = 0;
605
+ for (const binding of processedBindings) {
606
+ if (binding.isStatic || !binding.cellArg || !binding.fullExpression) continue;
607
+ const cellCode = generateExpr(code, binding.cellArg);
608
+ if (selectorMap.has(cellCode)) continue;
609
+ if (detectSelectorPattern(binding, itemParam)) {
610
+ selectorCounter++;
611
+ selectorMap.set(cellCode, {
612
+ subsVar: `_sel_${selectorCounter}_subs`,
613
+ prevVar: `_sel_${selectorCounter}_prev`,
614
+ });
615
+ }
616
+ }
617
+
618
+ // Generate selector setup lines (emitted BEFORE the forBlock call)
619
+ const selectorSetupLines = [];
620
+ for (const [cellCode, sel] of selectorMap) {
621
+ selectorSetupLines.push(` const ${sel.subsVar} = new Map();`);
622
+ selectorSetupLines.push(` let ${sel.prevVar} = get(${cellCode});`);
623
+ selectorSetupLines.push(` bind(${cellCode}, (next) => {`);
624
+ selectorSetupLines.push(` if (${sel.prevVar} === next) return;`);
625
+ selectorSetupLines.push(` ${sel.subsVar}.get(${sel.prevVar})?.(false);`);
626
+ selectorSetupLines.push(` ${sel.subsVar}.get(next)?.(true);`);
627
+ selectorSetupLines.push(` ${sel.prevVar} = next;`);
628
+ selectorSetupLines.push(` });`);
629
+ selectorSetupLines.push("");
630
+ usedImports.add("bind");
631
+ usedImports.add("get");
632
+ }
633
+ const selectorSetupCode =
634
+ selectorSetupLines.length > 0 ? selectorSetupLines.join("\n") + "\n" : "";
635
+
598
636
  for (const b of processedBindings) {
599
637
  if (!b.isStatic) {
600
638
  usedImports.add("bind");
@@ -703,9 +741,38 @@ function generateForBlock(
703
741
 
704
742
  // Track cleanup functions needed from bind() calls
705
743
  const cleanupVars = [];
744
+ // Track inline cleanup expressions from selector subscriptions
745
+ const selectorCleanups = [];
706
746
 
707
747
  // Bindings inside for block
708
748
  for (const binding of processedBindings) {
749
+ // Check for selector pattern before falling back to standard binding generation
750
+ if (!binding.isStatic && binding.cellArg && binding.fullExpression) {
751
+ const cellCode = generateExpr(code, binding.cellArg);
752
+ const sel = selectorMap.get(cellCode);
753
+ if (sel) {
754
+ const match = detectSelectorPattern(binding, itemParam);
755
+ if (match) {
756
+ const { targetVar, targetProperty } = binding;
757
+ // Initial value: evaluates with get(cell), Phase 4 inlines to cell.v
758
+ const initialExprCode = generateExpr(code, binding.fullExpression);
759
+ // Callback: replace the entire `get(cell) === itemParam` comparison with `active`
760
+ const callbackExprCode = generateExprWithReplacement(
761
+ code,
762
+ binding.fullExpression,
763
+ match.comparisonNode,
764
+ "active",
765
+ );
766
+ lines.push(` ${targetVar}.${targetProperty} = ${initialExprCode};`);
767
+ lines.push(` ${sel.subsVar}.set(${itemParam}, (active) => {`);
768
+ lines.push(` ${targetVar}.${targetProperty} = ${callbackExprCode};`);
769
+ lines.push(` });`);
770
+ selectorCleanups.push(`${sel.subsVar}.delete(${itemParam})`);
771
+ continue;
772
+ }
773
+ }
774
+ }
775
+
709
776
  const { bindCode, cleanupVar } = generateBindingWithCleanup(
710
777
  code,
711
778
  binding,
@@ -725,17 +792,23 @@ function generateForBlock(
725
792
  lines.push("");
726
793
  lines.push(` anchor.before(${firstElementVar});`);
727
794
 
728
- if (cleanupVars.length > 0) {
729
- const cleanupCalls = cleanupVars.map((v) => `${v}()`).join("; ");
795
+ const allCleanupCalls = [
796
+ ...cleanupVars.map((v) => `${v}()`),
797
+ ...selectorCleanups,
798
+ ]
799
+ .filter(Boolean)
800
+ .join("; ");
801
+
802
+ if (allCleanupCalls) {
730
803
  lines.push(
731
- ` return { start: ${firstElementVar}, end: ${firstElementVar}, cleanup: () => { ${cleanupCalls}; } };`,
804
+ ` return { start: ${firstElementVar}, end: ${firstElementVar}, cleanup: () => { ${allCleanupCalls}; } };`,
732
805
  );
733
806
  } else {
734
807
  lines.push(` return { start: ${firstElementVar}, end: ${firstElementVar} };`);
735
808
  }
736
809
  lines.push(` });`);
737
810
 
738
- return lines.join("\n");
811
+ return selectorSetupCode + lines.join("\n");
739
812
  }
740
813
 
741
814
  /**
@@ -1301,6 +1374,67 @@ function generatePropBinding(code, binding, usedImports) {
1301
1374
  return `setProp(${targetVar}, "${propName}", ${exprCode});`;
1302
1375
  }
1303
1376
 
1377
+ /**
1378
+ * Detect the selector pattern in a binding: get(outerCell) === itemParam
1379
+ *
1380
+ * This pattern is common for list selection (e.g. class={get(selected) === row ? "danger" : ""}).
1381
+ * When detected, the compiler replaces N individual bind() subscriptions with a single shared
1382
+ * dispatch function (O(1) per selection change) backed by a keyed Map.
1383
+ *
1384
+ * @param {object} binding - ProcessedBinding from bind-detector
1385
+ * @param {string} itemParam - The loop item parameter name (e.g., "row")
1386
+ * @returns {{ comparisonNode: object } | null}
1387
+ */
1388
+ function detectSelectorPattern(binding, itemParam) {
1389
+ const { fullExpression, getCallNode, needsTransform } = binding;
1390
+ // Must be a complex expression (needsTransform) so there's something beyond just get(cell)
1391
+ if (!fullExpression || !getCallNode || !needsTransform) return null;
1392
+
1393
+ function find(node) {
1394
+ if (!node || typeof node !== "object" || !node.type) return null;
1395
+
1396
+ if (node.type === "BinaryExpression" && node.operator === "===") {
1397
+ // get(cell) === itemParam
1398
+ if (
1399
+ node.left === getCallNode &&
1400
+ node.right.type === "Identifier" &&
1401
+ node.right.name === itemParam
1402
+ ) {
1403
+ return { comparisonNode: node };
1404
+ }
1405
+ // itemParam === get(cell)
1406
+ if (
1407
+ node.right === getCallNode &&
1408
+ node.left.type === "Identifier" &&
1409
+ node.left.name === itemParam
1410
+ ) {
1411
+ return { comparisonNode: node };
1412
+ }
1413
+ }
1414
+
1415
+ // Recurse into child nodes
1416
+ for (const key of Object.keys(node)) {
1417
+ if (key === "start" || key === "end" || key === "type" || key === "loc" || key === "extra")
1418
+ continue;
1419
+ const child = node[key];
1420
+ if (child && typeof child === "object") {
1421
+ if (Array.isArray(child)) {
1422
+ for (const c of child) {
1423
+ const result = find(c);
1424
+ if (result) return result;
1425
+ }
1426
+ } else if (child.type) {
1427
+ const result = find(child);
1428
+ if (result) return result;
1429
+ }
1430
+ }
1431
+ }
1432
+ return null;
1433
+ }
1434
+
1435
+ return find(fullExpression);
1436
+ }
1437
+
1304
1438
  /**
1305
1439
  * Generate expression code from AST node using original source
1306
1440
  */
@@ -458,7 +458,7 @@ 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();
@@ -469,7 +469,7 @@ function findBindCallbacks(ast, code) {
469
469
  * @param {number} stmtStart - Start position of containing statement
470
470
  * @param {number} stmtEnd - End position of containing statement
471
471
  */
472
- function processBindCall(bindExpr, stmtStart, stmtEnd) {
472
+ function processBindCall(bindExpr, stmtStart, stmtEnd, cleanupVarName = null) {
473
473
  const cellArg = bindExpr.arguments[0];
474
474
  const callbackArg = bindExpr.arguments[1];
475
475
  if (!cellArg || !callbackArg) return;
@@ -519,6 +519,7 @@ function findBindCallbacks(ast, code) {
519
519
  statementStart: stmtStart,
520
520
  statementEnd: stmtEnd,
521
521
  hasClosureVars,
522
+ cleanupVarName,
522
523
  });
523
524
  }
524
525
 
@@ -533,7 +534,12 @@ function findBindCallbacks(ast, code) {
533
534
  VariableDeclaration(path) {
534
535
  for (const decl of path.node.declarations) {
535
536
  if (decl.init && isBindCall(decl.init)) {
536
- processBindCall(decl.init, path.node.start, path.node.end);
537
+ processBindCall(
538
+ decl.init,
539
+ path.node.start,
540
+ path.node.end,
541
+ decl.id.type === "Identifier" ? decl.id.name : null,
542
+ );
537
543
  }
538
544
  }
539
545
  },
@@ -543,6 +549,35 @@ function findBindCallbacks(ast, code) {
543
549
  return bindCallbacks;
544
550
  }
545
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
+
546
581
  /**
547
582
  * Find element variables used in a callback body
548
583
  * Looks for element.property = ... patterns
@@ -740,6 +775,7 @@ export function inlineGetCalls(code, filename) {
740
775
 
741
776
  // Track bind statements to remove
742
777
  const bindStatementsToRemove = [];
778
+ const inlinedCleanupVars = new Set();
743
779
 
744
780
  // Track roqa imports for removal
745
781
  const importsToRemove = [];
@@ -1108,6 +1144,9 @@ export function inlineGetCalls(code, filename) {
1108
1144
 
1109
1145
  // Replace bind() call with ref assignment(s)
1110
1146
  if (refAssignment) {
1147
+ if (callback.cleanupVarName) {
1148
+ inlinedCleanupVars.add(callback.cleanupVarName);
1149
+ }
1111
1150
  s.overwrite(start, end, refAssignment);
1112
1151
  } else {
1113
1152
  // No element vars found - bind() can't be fully inlined
@@ -1117,6 +1156,60 @@ export function inlineGetCalls(code, filename) {
1117
1156
  }
1118
1157
  }
1119
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
+
1120
1213
  // Remove imports that are no longer needed
1121
1214
  // Collect all imports to remove first
1122
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 = [];