@ape-egg/vibe 1.7.2 → 1.8.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.
@@ -3,6 +3,7 @@ import affected from './affected.js';
3
3
  import hydrate from './hydrate.js';
4
4
  import { resolvePath, getItemKey, computeDiff } from './iteration-utils.js';
5
5
  import { resolveThisPath } from './utils.js';
6
+ import { managedNodes } from './conditionals.js';
6
7
 
7
8
  // Fast path for iteration rendering (opt-in via window.__VIBE_FAST_ITERATION__)
8
9
  // See: _vibe-compiled-iteration-batch.js for implementation details
@@ -138,32 +139,31 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
138
139
  let clonedNodes = [];
139
140
  let firstElement = null;
140
141
 
141
- // TEMPORARY: Disable fast path to test if it's causing duplication
142
- let canUseFastPath = false;
142
+ // Use cached tree when available: cloneTreeWithElements maps the existing parsed structure
143
+ // onto cloned DOM nodes, avoiding a full parse() call per iteration item.
144
+ // Only fall back to parse() when no cached tree exists (first parse of a new template).
145
+ // cloneTreeWithElements has a mapping bug with compiled mode's tree structure.
146
+ // Keep disabled until the root cause is fixed — the other optimizations
147
+ // (bulk replacement, evalInScope caching, DocumentFragment) cover the hot paths.
148
+ const useCachedTree = false;
143
149
 
144
150
  // Create a container for parsing (to capture all nodes including comment nodes like <!-- if -->)
145
151
  const parseContainer = document.createElement('div');
146
152
 
147
- if (canUseFastPath) {
148
- // Fast path: clone template nodes and map to cached tree structure
149
- for (let i = 0; i < templateNodes.length; i++) {
150
- const cloned = templateNodes[i].cloneNode(true);
151
- parseContainer.appendChild(cloned);
152
- if (!firstElement && cloned.nodeType === 1) {
153
- firstElement = cloned;
154
- }
153
+ // Clone template nodes into container
154
+ for (let i = 0; i < templateNodes.length; i++) {
155
+ const cloned = templateNodes[i].cloneNode(true);
156
+ parseContainer.appendChild(cloned);
157
+ if (!firstElement && cloned.nodeType === 1) {
158
+ firstElement = cloned;
155
159
  }
160
+ }
161
+
162
+ if (useCachedTree) {
163
+ // Fast path: map cached tree structure onto cloned DOM (no regex, no DOM walking)
156
164
  tree = cloneTreeWithElements(cachedTree, parseContainer);
157
165
  } else {
158
- // Slow path: clone and parse from scratch
159
- for (let i = 0; i < templateNodes.length; i++) {
160
- const cloned = templateNodes[i].cloneNode(true);
161
- parseContainer.appendChild(cloned);
162
- if (!firstElement && cloned.nodeType === 1) {
163
- firstElement = cloned;
164
- }
165
- }
166
- // Parse the entire container (includes all nodes + conditionals)
166
+ // Full parse: walk DOM, extract bindings, build tree from scratch
167
167
  tree = parse(parseContainer);
168
168
  }
169
169
 
@@ -194,24 +194,22 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
194
194
 
195
195
  // Create a proxied state with scoped variables (item, index, array)
196
196
  export const createScopedState = (globalState, localVars, parentScope = {}) => {
197
+ // Pre-compute the combined key list once at creation time.
198
+ // Avoids rebuilding 3 arrays + Set on every Object.keys() call.
199
+ const cachedKeys = [...new Set([
200
+ ...Object.keys(localVars),
201
+ ...Object.keys(parentScope),
202
+ ...Reflect.ownKeys(globalState),
203
+ ])];
204
+
197
205
  return new Proxy(globalState, {
198
206
  get(target, prop) {
199
- // 1. Check local scope first (item, index, array)
200
- if (prop in localVars) {
201
- return localVars[prop];
202
- }
203
-
204
- // 2. Check parent scope (for nested iterations)
205
- if (prop in parentScope) {
206
- return parentScope[prop];
207
- }
208
-
209
- // 3. Fall back to global state
207
+ if (prop in localVars) return localVars[prop];
208
+ if (prop in parentScope) return parentScope[prop];
210
209
  return Reflect.get(target, prop);
211
210
  },
212
211
 
213
212
  set(target, prop, value) {
214
- // Only allow setting global state, not local vars
215
213
  if (prop in localVars) {
216
214
  console.warn(`Cannot modify iteration variable '${prop}'`);
217
215
  return false;
@@ -223,21 +221,13 @@ export const createScopedState = (globalState, localVars, parentScope = {}) => {
223
221
  return Reflect.set(target, prop, value);
224
222
  },
225
223
 
226
- ownKeys(target) {
227
- // Return all keys: local vars, parent scope, and global state
228
- const localKeys = Object.keys(localVars);
229
- const parentKeys = Object.keys(parentScope);
230
- const globalKeys = Reflect.ownKeys(target);
231
- return [...new Set([...localKeys, ...parentKeys, ...globalKeys])];
232
- },
224
+ ownKeys() { return cachedKeys; },
233
225
 
234
226
  has(target, prop) {
235
- // Check if property exists in local scope, parent scope, or global state
236
227
  return prop in localVars || prop in parentScope || Reflect.has(target, prop);
237
228
  },
238
229
 
239
230
  getOwnPropertyDescriptor(target, prop) {
240
- // Provide property descriptor for local vars and parent scope
241
231
  if (prop in localVars) {
242
232
  return { configurable: true, enumerable: true, value: localVars[prop] };
243
233
  }
@@ -344,15 +334,10 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
344
334
  // Fall through to runtime path if compiled failed
345
335
  }
346
336
 
347
- // Fast path: opt-in via window.__VIBE_FAST_ITERATION__ (preview of Vibe Compiled)
348
- if (window.__VIBE_FAST_ITERATION__ && fastPath.canUseFastPath(template)) {
349
- fastPath.renderFast(iterationNode, array, state, parent, endComment);
350
- return;
351
- }
352
-
353
337
  // Standard path: clone and hydrate each item (handles nested iterations/conditionals)
354
338
  const instances = [];
355
- const templateNodes = [...template.element.childNodes];
339
+ const templateNodes = template.element.childNodes;
340
+ const frag = document.createDocumentFragment();
356
341
 
357
342
  for (let i = 0; i < array.length; i++) {
358
343
  const item = array[i];
@@ -362,8 +347,11 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
362
347
  // Clone, parse, hydrate
363
348
  const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
364
349
 
365
- // Insert cloned nodes
366
- clonedNodes.forEach((node) => parent.insertBefore(node, endComment));
350
+ // Collect nodes in DocumentFragment (single DOM insertion at end)
351
+ for (let j = 0; j < clonedNodes.length; j++) {
352
+ frag.appendChild(clonedNodes[j]);
353
+ if (clonedNodes[j].nodeType === 1) managedNodes.add(clonedNodes[j]);
354
+ }
367
355
 
368
356
  // Recursively render nested iterations and conditionals
369
357
  if (tree) {
@@ -375,6 +363,8 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
375
363
  instances.push({ element, tree, item, index: i, clonedNodes, scopedState });
376
364
  }
377
365
 
366
+ // Single DOM insertion for all items
367
+ parent.insertBefore(frag, endComment);
378
368
  iterationNode.runtime.instances = instances;
379
369
 
380
370
  // Mark comment as rendered (survives re-parsing since comment nodes persist in DOM)
@@ -394,9 +384,16 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
394
384
  // Handle this.property for component-scoped arrays
395
385
  const resolvedArrayPath = resolveThisPath(arrayPath, startComment.parentElement);
396
386
 
397
- const oldArray = resolvePath(oldState, resolvedArrayPath) || [];
387
+ const stateOldArray = resolvePath(oldState, resolvedArrayPath) || [];
398
388
  const newArray = resolvePath(newState, resolvedArrayPath) || [];
399
389
 
390
+ // Use instances (what's actually rendered) as ground truth for old array
391
+ // when oldState disagrees with the rendered count.
392
+ const instances = iterationNode.runtime.instances;
393
+ const oldArray = instances.length === stateOldArray.length
394
+ ? stateOldArray
395
+ : instances.map(inst => inst.item);
396
+
400
397
  // Compiled path: Use pre-compiled batch function when available
401
398
  if (compiled.canUseCompiled(iterationNode)) {
402
399
  const compiledMeta = compiled.getCompiledMeta(iterationNode);
@@ -415,20 +412,24 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
415
412
  // Fall through to runtime path if compiled failed
416
413
  }
417
414
 
418
- // Fast path: opt-in via window.__VIBE_FAST_ITERATION__ (preview of Vibe Compiled)
419
- // Use for bulk operations (large arrays or empty→full transitions)
420
- if (
421
- window.__VIBE_FAST_ITERATION__ &&
422
- fastPath.canUseFastPath(template) &&
423
- (isEmptyToFull || isFullToEmpty || isLargeArray)
424
- ) {
425
- fastPath.updateFast(iterationNode, newArray, newState, startComment, endComment);
415
+ // Bulk path: skip O(n²) LCS when arrays share no common items
416
+ // Handles empty→full, full→empty, and full replacement (no shared keys)
417
+ if (oldArray.length === 0 || newArray.length === 0) {
418
+ bulkReplace(iterationNode, newArray, newState, manifest, parentScope);
426
419
  return;
427
420
  }
428
421
 
429
- // Standard diff-based updates
430
422
  const oldKeys = oldArray.map((item, i) => getItemKey(item, i));
431
423
  const newKeys = newArray.map((item, i) => getItemKey(item, i));
424
+
425
+ // O(n) check: if no keys are shared, do bulk replacement instead of O(n²) LCS
426
+ const oldKeySet = new Set(oldKeys);
427
+ if (!newKeys.some(k => oldKeySet.has(k))) {
428
+ bulkReplace(iterationNode, newArray, newState, manifest, parentScope);
429
+ return;
430
+ }
431
+
432
+ // Standard diff-based updates (arrays share some common items)
432
433
  const operations = computeDiff(oldKeys, newKeys, oldArray, newArray);
433
434
 
434
435
  operations.forEach((op) => {
@@ -453,6 +454,65 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
453
454
  });
454
455
  };
455
456
 
457
+ // Bulk replacement: clear all DOM and re-render from scratch
458
+ // Used when arrays share no common keys (avoids O(n²) LCS)
459
+ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
460
+ const { itemAlias, indexAlias, template, startComment, endComment } = iterationNode.meta;
461
+ const parent = startComment.parentNode;
462
+
463
+ // Clear all existing DOM between comments using Range (single operation)
464
+ if (iterationNode.runtime.instances.length > 0) {
465
+ const range = document.createRange();
466
+ range.setStartAfter(startComment);
467
+ range.setEndBefore(endComment);
468
+ range.deleteContents();
469
+ }
470
+
471
+ if (newArray.length === 0) {
472
+ iterationNode.runtime.instances = [];
473
+ return;
474
+ }
475
+
476
+ // For simple templates (no nested iterations/conditionals, single root element),
477
+ // use batch string rendering: one string concatenation loop + one innerHTML parse
478
+ if (fastPath.canUseFastPath(template) && template.element.children.length <= 1) {
479
+ fastPath.renderFast(iterationNode, newArray, state, parent, endComment);
480
+ const instances = iterationNode.runtime.instances;
481
+ for (let i = 0; i < instances.length; i++) {
482
+ if (instances[i].element?.nodeType === 1) managedNodes.add(instances[i].element);
483
+ }
484
+ return;
485
+ }
486
+
487
+ // Complex templates: clone, parse, hydrate each item with DocumentFragment batching
488
+ const instances = [];
489
+ const templateNodes = template.element.childNodes;
490
+ const frag = document.createDocumentFragment();
491
+
492
+ for (let i = 0; i < newArray.length; i++) {
493
+ const item = newArray[i];
494
+ const localVars = { [itemAlias]: item, [indexAlias]: i };
495
+ const scopedState = createScopedState(state, localVars, parentScope);
496
+ const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
497
+
498
+ for (let j = 0; j < clonedNodes.length; j++) {
499
+ frag.appendChild(clonedNodes[j]);
500
+ if (clonedNodes[j].nodeType === 1) managedNodes.add(clonedNodes[j]);
501
+ }
502
+
503
+ if (tree) {
504
+ const nestedScope = { ...parentScope, ...localVars };
505
+ renderAllIterations(tree, scopedState, manifest, nestedScope);
506
+ _renderAllConditionals(tree, scopedState, manifest, nestedScope);
507
+ }
508
+
509
+ instances.push({ element, tree, item, index: i, clonedNodes, scopedState });
510
+ }
511
+
512
+ parent.insertBefore(frag, endComment);
513
+ iterationNode.runtime.instances = instances;
514
+ };
515
+
456
516
  // Add a new instance at the specified index
457
517
  const addInstance = (iterationNode, item, index, state, manifest, parentScope) => {
458
518
  const { itemAlias, indexAlias, template, startComment, endComment } = iterationNode.meta;
@@ -2,9 +2,13 @@
2
2
  import { ITERATION_START_REGEX, CONDITIONAL_START_REGEX } from './constants.js';
3
3
 
4
4
  // Resolve nested paths in state (e.g., "user.items" -> state.user.items)
5
+ // Supports bracket notation: "teams[0].combatants" -> state.teams[0].combatants
5
6
  export const resolvePath = (obj, path) => {
6
7
  if (!path || !obj) return undefined;
7
- return path.split('.').reduce((acc, part) => acc?.[part], obj);
8
+ // Split on dots and brackets: "a[0].b[1].c" → ["a", "0", "b", "1", "c"]
9
+ const parts = path.match(/[^.\[\]]+/g);
10
+ if (!parts) return undefined;
11
+ return parts.reduce((acc, part) => acc?.[part], obj);
8
12
  };
9
13
 
10
14
  // Clone template element preserving structure
package/runtime/parse.js CHANGED
@@ -140,6 +140,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
140
140
  // Store conditional metadata (use index for deterministic keys)
141
141
  const conditionalKey = `conditional_${i}`;
142
142
  result[conditionalKey] = {
143
+ _key: conditionalKey,
143
144
  type: 'conditional',
144
145
  meta: {
145
146
  expression,
@@ -60,23 +60,46 @@ export const getCompiledMeta = (iterationNode) => {
60
60
  };
61
61
 
62
62
  /**
63
- * Render iteration using pre-compiled batch function from manifest
63
+ * Build a scoped wrapper that puts state keys in scope for the batch function.
64
+ * The compiler generates (arr, $) => { ... } with bare variable names like `selectedCategory`,
65
+ * but those aren't parameters of the arrow function. We create a wrapper that defines
66
+ * state keys as parameters, then evaluates the batch function in that scope.
64
67
  */
65
- export const renderCompiled = (iterationNode, array, state, compiledMeta, parent, endComment) => {
66
- // Create function from string if not cached
68
+ const buildScopedFn = (batchFnStr, stateKeys) => {
69
+ return new Function(
70
+ ...stateKeys, 'arr',
71
+ `const $ = {${stateKeys.map(k => k + ':' + k).join(',')}};
72
+ const __batchFn = ${batchFnStr};
73
+ return __batchFn(arr, $);`
74
+ );
75
+ };
76
+
77
+ /**
78
+ * Call a compiled batch function with state keys spread into scope
79
+ */
80
+ const callCompiled = (iterationNode, array, state, compiledMeta) => {
67
81
  if (!iterationNode.runtime.compiledFn) {
68
82
  try {
69
- // compiledMeta.batchFn is a complete arrow function: (arr, $) => { ... }
70
- // Wrap in a function that returns it, then call to get the actual function
71
- iterationNode.runtime.compiledFn = new Function('return ' + compiledMeta.batchFn)();
83
+ const stateKeys = Object.keys(state);
84
+ iterationNode.runtime.compiledFn = buildScopedFn(compiledMeta.batchFn, stateKeys);
85
+ iterationNode.runtime.compiledStateKeys = stateKeys;
72
86
  } catch (e) {
73
87
  console.error('[compiled-iteration] Failed to create compiled function:', e);
74
- return false; // Signal failure
88
+ return null;
75
89
  }
76
90
  }
77
91
 
78
- // Build HTML using compiled function
79
- const html = iterationNode.runtime.compiledFn(array, state);
92
+ const keys = iterationNode.runtime.compiledStateKeys;
93
+ const values = keys.map(k => state[k]);
94
+ return iterationNode.runtime.compiledFn(...values, array);
95
+ };
96
+
97
+ /**
98
+ * Render iteration using pre-compiled batch function from manifest
99
+ */
100
+ export const renderCompiled = (iterationNode, array, state, compiledMeta, parent, endComment) => {
101
+ const html = callCompiled(iterationNode, array, state, compiledMeta);
102
+ if (html === null) return false;
80
103
 
81
104
  // Parse and insert
82
105
  if (parseTemplate) {
@@ -118,18 +141,8 @@ export const updateCompiled = (iterationNode, newArray, state, compiledMeta, sta
118
141
  return true;
119
142
  }
120
143
 
121
- // Create function if not cached
122
- if (!iterationNode.runtime.compiledFn) {
123
- try {
124
- iterationNode.runtime.compiledFn = new Function('return ' + compiledMeta.batchFn)();
125
- } catch (e) {
126
- console.error('Failed to create compiled function:', e);
127
- return false;
128
- }
129
- }
130
-
131
- // Build HTML using compiled function
132
- const html = iterationNode.runtime.compiledFn(newArray, state);
144
+ const html = callCompiled(iterationNode, newArray, state, compiledMeta);
145
+ if (html === null) return false;
133
146
 
134
147
  // Parse and insert
135
148
  if (parseTemplate) {
package/runtime/state.js CHANGED
@@ -1,11 +1,29 @@
1
1
  // Track which objects are already proxied to avoid double-wrapping
2
2
  const proxyCache = new WeakMap();
3
3
 
4
+ // Batching: collect mutations and flush once per microtask
5
+ let pendingFlush = false;
6
+ let flushCallback = null;
7
+ const changedProps = new Set();
8
+
9
+ const scheduleFlush = () => {
10
+ if (!pendingFlush) {
11
+ pendingFlush = true;
12
+ queueMicrotask(() => {
13
+ pendingFlush = false;
14
+ const props = [...changedProps];
15
+ changedProps.clear();
16
+ if (flushCallback) flushCallback(props);
17
+ });
18
+ }
19
+ };
20
+
4
21
  // Deep proxy: recursively wrap nested objects and arrays
5
22
  const createDeepProxy = (target, rerender, rootState = null, rootProp = null) => {
6
23
  // For root level, rootState is the target itself
7
24
  if (rootState === null) {
8
25
  rootState = target;
26
+ flushCallback = (props) => rerender(props);
9
27
  }
10
28
 
11
29
  // Check cache first
@@ -19,13 +37,11 @@ const createDeepProxy = (target, rerender, rootState = null, rootProp = null) =>
19
37
 
20
38
  // Only trigger rerender if value actually changed
21
39
  if (oldValue !== value) {
22
- // Perform the mutation
23
40
  const ref = Reflect.set(obj, prop, value);
24
41
 
25
- // Trigger rerender with changed state
26
- // If we're nested, use the root prop; otherwise use the prop itself
27
- const changedProp = rootProp || prop;
28
- rerender({ [changedProp]: rootState[changedProp] }, null);
42
+ // Track which root-level property changed (for selective extraction)
43
+ changedProps.add(rootProp || prop);
44
+ scheduleFlush();
29
45
 
30
46
  return ref;
31
47
  }
package/runtime/utils.js CHANGED
@@ -2,6 +2,10 @@
2
2
  let hashCounter = 0;
3
3
  export const hash = () => `_${hashCounter++}`;
4
4
 
5
+ // Function compilation cache: avoids creating new Function() for repeated expressions
6
+ // Key: normalized expression + '\0' + state keys joined by '\0'
7
+ const fnCache = new Map();
8
+
5
9
  // Evaluate expression in the context of state
6
10
  // Handles @[this.property] for component state and @[property] for global state
7
11
  export const evalInScope = (expr, state, element = null) => {
@@ -18,13 +22,28 @@ export const evalInScope = (expr, state, element = null) => {
18
22
  }
19
23
  }
20
24
 
21
- // Add $ to scope for this.property references
22
- const effectiveState = { ...state, $: state };
25
+ // Get state keys once (triggers ownKeys trap only once for Proxies)
26
+ const stateKeys = Object.keys(state);
27
+ const keyCount = stateKeys.length;
28
+
29
+ // Cache lookup: expression + key signature → compiled function
30
+ // All iteration instances share the same state shape, so this hits cache 999/1000 times
31
+ const cacheKey = normalized + '\0' + stateKeys.join('\0');
32
+ let fn = fnCache.get(cacheKey);
33
+ if (!fn) {
34
+ const allKeys = new Array(keyCount + 1);
35
+ for (let i = 0; i < keyCount; i++) allKeys[i] = stateKeys[i];
36
+ allKeys[keyCount] = '$';
37
+ fn = new Function(...allKeys, `'use strict'; return (${normalized})`);
38
+ fnCache.set(cacheKey, fn);
39
+ }
23
40
 
24
- const keys = Object.keys(effectiveState);
25
- const values = Object.values(effectiveState);
41
+ // Build values array matching the cached function's parameter order
42
+ const values = new Array(keyCount + 1);
43
+ for (let i = 0; i < keyCount; i++) values[i] = state[stateKeys[i]];
44
+ values[keyCount] = state;
26
45
 
27
- const result = new Function(...keys, `'use strict'; return (${normalized})`)(...values);
46
+ const result = fn(...values);
28
47
 
29
48
  // If result is undefined and we're accessing a component property, try case-insensitive match
30
49
  // This handles HTML lowercasing attribute names like @[this.iconName] -> @[this.iconname]