@ape-egg/vibe 1.9.9 → 2.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.
@@ -51,8 +51,11 @@ const copyString = (value, i, push) => {
51
51
  // Rewrite standalone references to loop-variable aliases inside an event-handler
52
52
  // expression into `$scope(this,'alias')` calls. Skips `@[...]` binding spans
53
53
  // (they keep their existing hydrate-time stringifying behavior), string literals,
54
- // and member accesses, so only identifiers that genuinely name a loop alias are
55
- // touched.
54
+ // member accesses, and object-literal keys (`{ alias: x }` keeps its key; the
55
+ // shorthand `{ alias }` expands to `{ alias: $scope(this,'alias') }`), so only
56
+ // identifiers that genuinely name a loop alias are touched. Bracket frames carry
57
+ // a pending-ternary count per nesting level, which is what tells an object key's
58
+ // `:` apart from a ternary's.
56
59
  export const rewriteHandlerAliases = (value, aliasSet) => {
57
60
  if (!aliasSet || aliasSet.size === 0 || typeof value !== 'string') return value;
58
61
 
@@ -62,6 +65,8 @@ export const rewriteHandlerAliases = (value, aliasSet) => {
62
65
  };
63
66
  let i = 0;
64
67
  const n = value.length;
68
+ const frames = [{ bracket: '', ternaries: 0 }];
69
+ const frame = () => frames[frames.length - 1];
65
70
 
66
71
  while (i < n) {
67
72
  const ch = value[i];
@@ -92,6 +97,37 @@ export const rewriteHandlerAliases = (value, aliasSet) => {
92
97
  continue;
93
98
  }
94
99
 
100
+ if (ch === '(' || ch === '[' || ch === '{') {
101
+ frames.push({ bracket: ch, ternaries: 0 });
102
+ push(ch);
103
+ i++;
104
+ continue;
105
+ }
106
+ if (ch === ')' || ch === ']' || ch === '}') {
107
+ if (frames.length > 1) frames.pop();
108
+ push(ch);
109
+ i++;
110
+ continue;
111
+ }
112
+ if (ch === '?') {
113
+ const next = value[i + 1];
114
+ if (next === '.' || next === '?') {
115
+ push(ch + next);
116
+ i += 2;
117
+ continue;
118
+ }
119
+ frame().ternaries++;
120
+ push(ch);
121
+ i++;
122
+ continue;
123
+ }
124
+ if (ch === ':') {
125
+ if (frame().ternaries > 0) frame().ternaries--;
126
+ push(ch);
127
+ i++;
128
+ continue;
129
+ }
130
+
95
131
  // Identifier — rewrite when it's a standalone alias reference.
96
132
  if (IDENT_START.test(ch)) {
97
133
  let j = i + 1;
@@ -99,7 +135,17 @@ export const rewriteHandlerAliases = (value, aliasSet) => {
99
135
  const ident = value.slice(i, j);
100
136
  const isMember = lastNonSpace(out) === '.';
101
137
  if (!isMember && aliasSet.has(ident)) {
102
- push(`$scope(this,'${ident}')`);
138
+ let k = j;
139
+ while (k < n && /\s/.test(value[k])) k++;
140
+ const next = value[k] || '';
141
+ const prev = lastNonSpace(out);
142
+ const inObject = frame().bracket === '{';
143
+ const isKey = inObject && next === ':' && frame().ternaries === 0;
144
+ const isShorthand =
145
+ inObject && (prev === '{' || prev === ',') && (next === ',' || next === '}');
146
+ if (isKey) push(ident);
147
+ else if (isShorthand) push(`${ident}: $scope(this,'${ident}')`);
148
+ else push(`$scope(this,'${ident}')`);
103
149
  } else {
104
150
  push(ident);
105
151
  }
package/runtime/parse.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  DOM_ELEMENT_PROPERTIES,
8
8
  DEHYDRATE_CLASS_OR_ATTR,
9
9
  THIS_PROP_REGEX,
10
+ STATE_THIS_PROP_REGEX,
10
11
  } from './constants.js';
11
12
  import { rewriteHandlerAliases } from './loop-scope.js';
12
13
 
@@ -57,6 +58,13 @@ const captureAttributeBindings = (element, aliasSet) => {
57
58
  if (v.includes('this.')) {
58
59
  const componentId = findComponentIdForElement(element);
59
60
  if (componentId) {
61
+ // `$.this.X` (component-state write through the root) must be
62
+ // consumed as one reference BEFORE the bare `this.X` pass — that
63
+ // pass alone would leave the `$.` prefix behind and produce
64
+ // `$.$['id'].X`. Runtime-fetched components arrive with this form
65
+ // already rewritten by component.js; compiled pages inline the
66
+ // authored form, so parse meets it raw.
67
+ v = v.replace(STATE_THIS_PROP_REGEX, (_, prop) => `$['${componentId}'].${prop}`);
60
68
  v = v.replace(THIS_PROP_REGEX, (match, prop) =>
61
69
  DOM_ELEMENT_PROPERTIES.has(prop) ? match : `$['${componentId}'].${prop}`,
62
70
  );
@@ -374,172 +374,73 @@ export const restoreMarkersFromManifest = (
374
374
  }
375
375
  }
376
376
 
377
- // Handle conditional and iteration children - process them at THIS level before recursing
378
- // These are comment nodes in the DOM, not elements, so we need to find them here
377
+ // Restore iterations/conditionals at THIS level by their childNodes index.
378
+ // The manifest key encodes the start comment's pre-stamp position
379
+ // (`conditional_13` = element.childNodes[13]); processing in ascending
380
+ // order keeps every index valid, because each restoration returns its
381
+ // region to the exact pre-stamp node count before the next index is
382
+ // consulted. Locating by index — not by expression text — is what
383
+ // disambiguates sibling directives that share the same expression.
379
384
  if (tree.children) {
380
- for (const key in tree.children) {
385
+ const directives = Object.keys(tree.children)
386
+ .filter((key) => {
387
+ const type = tree.children[key].type;
388
+ return type === "iteration" || type === "conditional";
389
+ })
390
+ .map((key) => ({ key, index: Number(key.match(/_(\d+)$/)?.[1]) }))
391
+ .filter(({ index }) => Number.isInteger(index))
392
+ .sort((a, b) => a.index - b.index);
393
+
394
+ for (const { key, index } of directives) {
381
395
  const childTree = tree.children[key];
396
+ const restoration = childTree.compiled?.restoration;
382
397
 
383
- // Handle iteration restoration
384
- if (childTree.type === "iteration" && childTree.compiled?.restoration) {
385
- const restoration = childTree.compiled.restoration;
386
-
387
- // Find iteration comment by matching the expression
388
- let startComment = null;
389
- let endComment = null;
390
- let depth = 0;
391
-
392
- const walker = document.createTreeWalker(
393
- element,
394
- NodeFilter.SHOW_COMMENT,
395
- );
396
- while (walker.nextNode()) {
397
- const comment = walker.currentNode;
398
- const trimmed = comment.textContent.trim();
399
-
400
- // Skip already processed iterations
401
- if (comment._vibeProcessed) continue;
402
-
403
- // Match by expression: "each items as item, i"
404
- const expectedComment = `each ${restoration.expression}`;
405
- if (trimmed === expectedComment && !startComment) {
406
- startComment = comment;
407
- depth = 1;
408
- } else if (startComment) {
409
- if (trimmed.startsWith("each ")) {
410
- depth++;
411
- } else if (trimmed === "/each") {
412
- depth--;
413
- if (depth === 0) {
414
- endComment = comment;
415
- break;
416
- }
417
- }
418
- }
419
- }
420
-
421
- // Always delete the iteration node from tree - runtime will re-create it
422
- // Do this even if comments weren't found (they might have been removed by parent restoration)
423
- delete tree.children[key];
424
-
425
- if (startComment && endComment) {
426
- // Mark as processed
427
- startComment._vibeProcessed = true;
428
- endComment._vibeProcessed = true;
429
-
430
- const parent = startComment.parentNode;
431
- const insertionPoint = endComment.nextSibling;
398
+ // Always delete the directive node from the tree — the runtime
399
+ // re-creates it when it parses the restored DOM.
400
+ delete tree.children[key];
432
401
 
433
- // Remove all pre-rendered content between comments
434
- let current = startComment.nextSibling;
435
- while (current && current !== endComment) {
436
- const next = current.nextSibling;
437
- if (current.nodeType !== Node.COMMENT_NODE) {
438
- current.remove();
439
- }
440
- current = next;
441
- }
442
-
443
- // Insert the template (single item)
444
- const tempContainer = document.createElement("div");
445
- tempContainer.innerHTML = restoration.template;
446
-
447
- const fragment = document.createDocumentFragment();
448
- while (tempContainer.firstChild) {
449
- fragment.appendChild(tempContainer.firstChild);
450
- }
451
- parent.insertBefore(fragment, endComment);
452
- }
402
+ if (!restoration?.template) continue;
453
403
 
404
+ const startComment = element.childNodes[index];
405
+ if (!startComment || startComment.nodeType !== Node.COMMENT_NODE) {
454
406
  continue;
455
407
  }
456
408
 
457
- if (
458
- childTree.type === "conditional" &&
459
- childTree.compiled?.restoration
460
- ) {
461
- const restoration = childTree.compiled.restoration;
462
-
463
- // Find conditional comment markers in the current element
464
- // But SKIP conditionals that are inside iteration blocks
465
- const walker = document.createTreeWalker(
466
- element,
467
- NodeFilter.SHOW_COMMENT,
468
- );
469
- let startComment = null;
470
- let endComment = null;
471
- let insideIteration = false;
472
- let conditionalDepth = 0;
473
-
474
- while (walker.nextNode()) {
475
- const comment = walker.currentNode;
476
- const text = comment.textContent.trim();
477
-
478
- // Track if we're inside an iteration block
479
- if (text.startsWith("each ")) {
480
- insideIteration = true;
481
- continue;
482
- } else if (text === "/each") {
483
- insideIteration = false;
484
- continue;
485
- }
486
-
487
- // Skip conditionals inside iterations - runtime will handle them
488
- if (insideIteration) continue;
489
-
490
- // Skip already processed conditionals
491
- if (comment._vibeProcessed) continue;
492
-
493
- if (text.startsWith("if")) {
494
- if (!startComment) {
495
- const expression = childTree.meta?.expression;
496
- const expectedText = expression ? "if " + expression : null;
497
- if (expectedText && text === expectedText) {
498
- startComment = comment;
499
- conditionalDepth = 1;
500
- }
501
- } else {
502
- // Track nested conditionals
503
- conditionalDepth++;
504
- }
505
- } else if (text === "/if" && startComment) {
506
- conditionalDepth--;
507
- if (conditionalDepth === 0) {
508
- endComment = comment;
509
- break;
510
- }
409
+ // Find the matching end marker at this sibling level, depth-counted
410
+ // so nested same-kind directives inside the region don't end it early.
411
+ const isIteration = childTree.type === "iteration";
412
+ const openPrefix = isIteration ? "each " : "if ";
413
+ const closeMarker = isIteration ? "/each" : "/if";
414
+ let depth = 0;
415
+ let endComment = null;
416
+ for (let cur = startComment.nextSibling; cur; cur = cur.nextSibling) {
417
+ if (cur.nodeType !== Node.COMMENT_NODE) continue;
418
+ const text = cur.textContent.trim();
419
+ if (text.startsWith(openPrefix)) {
420
+ depth++;
421
+ } else if (text === closeMarker) {
422
+ if (depth === 0) {
423
+ endComment = cur;
424
+ break;
511
425
  }
426
+ depth--;
512
427
  }
513
-
514
- // Always delete the conditional node from tree - runtime will re-create it
515
- // Do this even if comments weren't found (they might have been removed by parent restoration)
516
- delete tree.children[key];
517
-
518
- if (startComment && endComment) {
519
- // Mark as processed
520
- startComment._vibeProcessed = true;
521
- endComment._vibeProcessed = true;
522
-
523
- // Remove ALL pre-rendered content between start and end comments
524
- // This includes the <!-- else --> marker from compiled HTML
525
- let current = startComment.nextSibling;
526
- while (current && current !== endComment) {
527
- const next = current.nextSibling;
528
- current.remove(); // Remove ALL nodes, including comment nodes
529
- current = next;
530
- }
531
-
532
- // Insert the template content BEFORE endComment
533
- const tempContainer = document.createElement("div");
534
- tempContainer.innerHTML = restoration.template;
535
-
536
- const fragment = document.createDocumentFragment();
537
- while (tempContainer.firstChild) {
538
- fragment.appendChild(tempContainer.firstChild);
539
- }
540
- endComment.parentNode.insertBefore(fragment, endComment);
541
- }
542
428
  }
429
+ if (!endComment) continue;
430
+
431
+ // Drop the stamped content (including the <!-- else --> marker and
432
+ // any stamped-row comments) and re-insert the pre-stamp template —
433
+ // node-for-node identical to what the manifest was built against.
434
+ let current = startComment.nextSibling;
435
+ while (current && current !== endComment) {
436
+ const next = current.nextSibling;
437
+ current.remove();
438
+ current = next;
439
+ }
440
+
441
+ const tpl = document.createElement("template");
442
+ tpl.innerHTML = restoration.template;
443
+ endComment.parentNode.insertBefore(tpl.content, endComment);
543
444
  }
544
445
  }
545
446
 
package/runtime/utils.js CHANGED
@@ -46,13 +46,23 @@ export const setRootState = (proxy) => { rootProxy = proxy; };
46
46
  // delegate to the live root (their target is `$`), so they need no wrapper.
47
47
  const dollarCache = new WeakMap();
48
48
  const RESERVED_PROBE = 'unsafe'; // reserved method present on root-backed states, absent on plain snapshots
49
+ // The fallback serves ONLY the root's non-enumerable helper methods. A state
50
+ // key that is merely missing from this snapshot must read as undefined — the
51
+ // old/new snapshot diff depends on it. Letting it leak through to the live
52
+ // root would make both sides of the diff read the same current value (e.g. a
53
+ // component state bucket registered mid-cycle), silently defeating change
54
+ // detection.
55
+ const rootHelper = (k) => {
56
+ const desc = Object.getOwnPropertyDescriptor(rootProxy, k);
57
+ return desc && !desc.enumerable;
58
+ };
49
59
  const dollarFor = (state) => {
50
60
  if (!rootProxy || state === rootProxy || RESERVED_PROBE in state) return state;
51
61
  let wrapped = dollarCache.get(state);
52
62
  if (!wrapped) {
53
63
  wrapped = new Proxy(state, {
54
- get: (t, k) => (k in t ? t[k] : rootProxy[k]),
55
- has: (t, k) => k in t || k in rootProxy,
64
+ get: (t, k) => (k in t ? t[k] : rootHelper(k) ? rootProxy[k] : undefined),
65
+ has: (t, k) => k in t || rootHelper(k),
56
66
  });
57
67
  dollarCache.set(state, wrapped);
58
68
  }