@ape-egg/vibe 2.1.15 → 2.1.17

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
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.17] - 2026-06-22
4
+
5
+ ### Fixed
6
+
7
+ - **Compiled inlined components inside an iteration shared one local-state bucket across all rows** (`runtime/iterate.js`) — in compiled mode each inlined component carries a fixed `data-vibe-component-id` (`_cN`) with its `@[this.x]` bindings and `$.this.x` handlers stamped to that id. An iteration clones its template once per row, so every row reused the same baked id — and therefore the same `component({...})` state bucket — and opening one row's ability drawer opened them all (two brawler slots both resolving to `_c2.open`). `initializeBlock` now isolates components per row: `isolateInlinedComponentIds` remaps every baked `_cN` in the clone to a fresh `generateComponentId()` and rewrites the `_cN.prop` references in attributes and text (boundary-anchored so `_c2` never matches inside `_c20`), then runs the row's inlined `vibe-module` setup scripts so each registers isolated state under its fresh id — mirroring the conditional-branch path. Batch render is also disabled for templates carrying an inlined component script (it emits one shared HTML string per row, which would duplicate the baked id), routing them through the clone-and-isolate path instead. Runtime mode is unaffected: there are no baked ids there — components are still `<component src>`.
8
+
9
+ ## [2.1.16] - 2026-06-22
10
+
11
+ ### Fixed
12
+
13
+ - **Compiler 1.9.9 → 2.0.0 — bare prop substitution corrupted matching text inside string literals** (`compiler/src/parser/html.rs`, `runtime/component.js`) — the bare-prop-identifier rewrite added in 2.1.14 (so `onclick="pick(item)"` resolves the live prop) replaced *every* whole-word occurrence of the prop name, including ones inside a quoted string. A prop named `slot` rewrote the selector in `closest('brawler-slot')`, corrupting it. Both the compiler's `substitute_identifier` and the runtime's `substituteInExpr` now track string-literal boundaries (`'`, `"`, and backtick, honouring escapes; template `${…}` counts as part of the literal) and substitute only the code spans between them, leaving identifiers inside string literals intact. The two sides stay byte-for-byte identical, preserving compiled/runtime parity.
14
+
3
15
  ## [2.1.15] - 2026-06-22
4
16
 
5
17
  ### Fixed
@@ -1599,7 +1599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1599
1599
 
1600
1600
  [[package]]
1601
1601
  name = "vibe-compiler"
1602
- version = "1.9.9"
1602
+ version = "2.0.0"
1603
1603
  dependencies = [
1604
1604
  "clap",
1605
1605
  "colored",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vibe-compiler"
3
- version = "1.9.9"
3
+ version = "2.0.0"
4
4
  edition = "2021"
5
5
  description = "Vibe framework compiler - compiles Vibe source files into optimized output"
6
6
  authors = ["Kim Korte"]
@@ -542,7 +542,32 @@ fn substitute_identifier(expr: &str, name: &str, replacement: &str) -> String {
542
542
  }
543
543
  let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
544
544
  let mut i = 0;
545
+ // Delimiter of the string literal we're currently inside, or 0 when in code.
546
+ // A prop identifier that appears inside a quoted string (e.g. the selector in
547
+ // `closest('brawler-slot')`) must be left intact, mirroring the runtime's
548
+ // substituteInExpr. Template `${…}` counts as part of the literal here.
549
+ let mut quote: u8 = 0;
545
550
  while i < bytes.len() {
551
+ let b = bytes[i];
552
+ if quote != 0 {
553
+ out.push(b);
554
+ if b == b'\\' && i + 1 < bytes.len() {
555
+ out.push(bytes[i + 1]);
556
+ i += 2;
557
+ continue;
558
+ }
559
+ if b == quote {
560
+ quote = 0;
561
+ }
562
+ i += 1;
563
+ continue;
564
+ }
565
+ if b == b'\'' || b == b'"' || b == b'`' {
566
+ quote = b;
567
+ out.push(b);
568
+ i += 1;
569
+ continue;
570
+ }
546
571
  if i + nlen <= bytes.len()
547
572
  && bytes[i..i + nlen].eq_ignore_ascii_case(nb)
548
573
  && (i == 0 || (!is_ident_byte(bytes[i - 1]) && bytes[i - 1] != b'.'))
@@ -551,7 +576,7 @@ fn substitute_identifier(expr: &str, name: &str, replacement: &str) -> String {
551
576
  out.extend_from_slice(replacement.as_bytes());
552
577
  i += nlen;
553
578
  } else {
554
- out.push(bytes[i]);
579
+ out.push(b);
555
580
  i += 1;
556
581
  }
557
582
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "2.1.15",
3
+ "version": "2.1.17",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -350,7 +350,23 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
350
350
  'gi'
351
351
  );
352
352
 
353
- const substituteInExpr = (expr, replacement) => expr.replace(idRegex, replacement);
353
+ // Rewrite the identifier only outside string literals. A prop/alias that also
354
+ // appears inside a quoted string — e.g. the selector in `closest('brawler-slot')`
355
+ // when the prop is `slot` — must be left intact. Split on string literals
356
+ // (single/double/backtick, honouring escapes) and substitute only the code spans
357
+ // between them. Template `${…}` interpolation counts as part of the literal here.
358
+ const stringLiteral = /(['"`])(?:\\.|(?!\1)[^\\])*\1/g;
359
+ const substituteInExpr = (expr, replacement) => {
360
+ let out = '';
361
+ let last = 0;
362
+ let m;
363
+ stringLiteral.lastIndex = 0;
364
+ while ((m = stringLiteral.exec(expr))) {
365
+ out += expr.slice(last, m.index).replace(idRegex, replacement) + m[0];
366
+ last = m.index + m[0].length;
367
+ }
368
+ return out + expr.slice(last).replace(idRegex, replacement);
369
+ };
354
370
 
355
371
  if (bindingMatch) {
356
372
  const path = bindingMatch[1];
@@ -16,6 +16,8 @@ import {
16
16
  // Pre-compiled iteration optimization (production)
17
17
  import * as compiled from './pre-compiled-iterations.js';
18
18
 
19
+ import { generateComponentId, executeCompiledComponentScriptsIn } from './component.js';
20
+
19
21
  // Runtime batch-render helpers for full-replacement of simple templates.
20
22
  // Build an HTML string via template-literal compilation, then parse once —
21
23
  // avoids per-item clone/parse/hydrate in the hot path.
@@ -56,6 +58,14 @@ const hasNestedStructures = (tree) => {
56
58
  const hasComponentSrc = (templateEl) =>
57
59
  !!templateEl.querySelector?.('component[src], div.component[src]');
58
60
 
61
+ // A compiled inlined component with its own setup script carries component-local
62
+ // state (`component({...})` → `$._cN`). Batch render emits one shared HTML string
63
+ // per row, which would duplicate the baked `_cN` id across rows and collapse
64
+ // their state into one bucket. Route these through the clone path, where
65
+ // initializeBlock isolates each row's component ids.
66
+ const hasInlinedComponentScript = (templateEl) =>
67
+ !!templateEl.querySelector?.('script[type="vibe-module"]');
68
+
59
69
  // `__vibeForceClonePath` is a debug/test escape hatch — set it on globalThis to
60
70
  // route every iteration through the clone+hydrate path, even templates that
61
71
  // would otherwise qualify for batch. Used by the batch-vs-clone-equivalence
@@ -65,7 +75,8 @@ const canUseBatchRender = (template) =>
65
75
  !globalThis.__vibeForceClonePath &&
66
76
  !hasNestedStructures(template) &&
67
77
  template.element.children.length <= 1 &&
68
- !hasComponentSrc(template.element);
78
+ !hasComponentSrc(template.element) &&
79
+ !hasInlinedComponentScript(template.element);
69
80
 
70
81
  // Patterns used by compileBatchFn to recognize bindings in attribute-name and
71
82
  // attribute-value positions. The inner alternation mirrors BINDING_INNER from
@@ -795,7 +806,57 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
795
806
  * that runs while clones are still detached.
796
807
  * @returns {Object} { element, tree, clonedNodes }
797
808
  */
798
- export const initializeBlock = (templateNodes, scopedState, cachedTree = null, componentId = null, aliasSet = undefined) => {
809
+ // Compiled mode bakes a fixed `data-vibe-component-id` into each inlined
810
+ // component and stamps its `@[this.x]` bindings / `$.this.x` handlers to that
811
+ // id. An iteration clones its template once per row, so every row would
812
+ // otherwise share that one id — and thus one local-state bucket. (Two brawler
813
+ // slots both resolving to `_c2.open` is why opening one ability drawer opened
814
+ // them all.) Per row, remap every baked id in the clone to a fresh one and
815
+ // rewrite the references to it; the caller then runs the row's component
816
+ // scripts so each registers isolated state under its fresh id. Runtime mode has
817
+ // no baked ids here (components are still `<component src>`), so this no-ops.
818
+ const COMPONENT_ID = /^_c\d+$/;
819
+
820
+ const isolateInlinedComponentIds = (container) => {
821
+ const remap = new Map();
822
+ for (const el of container.querySelectorAll('[data-vibe-component-id]')) {
823
+ const oldId = el.getAttribute('data-vibe-component-id');
824
+ if (!COMPONENT_ID.test(oldId)) continue;
825
+ if (!remap.has(oldId)) remap.set(oldId, generateComponentId());
826
+ el.setAttribute('data-vibe-component-id', remap.get(oldId));
827
+ }
828
+ if (!remap.size) return false;
829
+
830
+ // `_c2` must not match inside `_c20` or a longer identifier, so anchor on a
831
+ // non-word/`$` boundary before and a non-digit/word after. Bindings always
832
+ // read the id as `_cN.prop`, so the trailing `.` satisfies the lookahead.
833
+ const refs = [...remap].map(([oldId, newId]) => [
834
+ new RegExp(`(?<![\\w$])${oldId}(?![\\w\\d])`, 'g'),
835
+ newId,
836
+ ]);
837
+ const rewrite = (str) => {
838
+ let out = str;
839
+ for (const [re, newId] of refs) out = out.replace(re, newId);
840
+ return out;
841
+ };
842
+ const walk = (node) => {
843
+ if (node.nodeType === 1) {
844
+ for (const attr of node.attributes) {
845
+ if (attr.value.includes('_c')) {
846
+ const next = rewrite(attr.value);
847
+ if (next !== attr.value) attr.value = next;
848
+ }
849
+ }
850
+ for (const child of node.childNodes) walk(child);
851
+ } else if (node.nodeType === 3 && node.textContent.includes('_c')) {
852
+ node.textContent = rewrite(node.textContent);
853
+ }
854
+ };
855
+ for (const node of container.childNodes) walk(node);
856
+ return true;
857
+ };
858
+
859
+ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, componentId = null, aliasSet = undefined, isolateComponents = false) => {
799
860
  let tree;
800
861
  let clonedNodes = [];
801
862
  let firstElement = null;
@@ -833,6 +894,13 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, c
833
894
  if (el._vibeSlotContent === undefined) el._vibeSlotContent = el.innerHTML;
834
895
  }
835
896
 
897
+ // Give this row its own component ids (compiled mode) before parse() reads the
898
+ // bindings, then run the row's inlined setup scripts so each registers its own
899
+ // local state under the fresh id — mirroring the conditional-branch path.
900
+ if (isolateComponents && isolateInlinedComponentIds(parseContainer)) {
901
+ executeCompiledComponentScriptsIn([...parseContainer.childNodes]);
902
+ }
903
+
836
904
  if (useCachedTree) {
837
905
  // Fast path: map cached tree structure onto cloned DOM (no regex, no DOM walking)
838
906
  tree = cloneTreeWithElements(cachedTree, parseContainer);
@@ -1424,7 +1492,7 @@ const buildInstance = (iterationNode, item, index, state, parentScope, liveItem)
1424
1492
  const localVars = { [itemAlias]: liveItem, [indexAlias]: index };
1425
1493
  const scopedState = createScopedState(state, localVars, parentScope);
1426
1494
  const componentId = findComponentIdForElement(startComment.parentElement);
1427
- const built = initializeBlock([...template.element.childNodes], scopedState, template, componentId, new Set(iterationNode.meta.scopeAliases));
1495
+ const built = initializeBlock([...template.element.childNodes], scopedState, template, componentId, new Set(iterationNode.meta.scopeAliases), true);
1428
1496
  const scopeAliases = new Set([itemAlias, indexAlias, ...(iterationNode.meta.scopeAliases || [])]);
1429
1497
  resolveIterationComponentProps(built.clonedNodes, scopedState, scopeAliases);
1430
1498
  return { ...built, scopedState, localVars, liveItem };