@ape-egg/vibe 1.9.0 → 1.9.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.
@@ -1,7 +1,7 @@
1
1
  import { updateIteration } from './iterate.js';
2
2
  import { updateConditional } from './conditionals.js';
3
3
  import { VALUE_ATTRS, DOM_PROPERTIES, BINDING_REGEX, PURE_BINDING_REGEX } from './constants.js';
4
- import { evalInScope } from './utils.js';
4
+ import { evalInScope, resolveCaseInsensitivePath } from './utils.js';
5
5
 
6
6
  export default (affected, state, manifest = {}, oldState = {}) => {
7
7
  affected.forEach((aff) => {
@@ -24,18 +24,15 @@ export default (affected, state, manifest = {}, oldState = {}) => {
24
24
  if (aff.type === 'nameBinding') {
25
25
  const { nameBinding, matchInner, element } = aff;
26
26
  try {
27
- // HTML lowercases attribute names, so we need case-insensitive lookup
28
- // Try exact match first, then try finding a case-insensitive match
27
+ // HTML lowercases attribute names, so we need case-insensitive lookup.
28
+ // Try exact match first, then walk the path case-insensitively if the
29
+ // exact lookup returned nothing — this recovers camelCase property
30
+ // names in dotted paths like `<icon @[fx.convertsIcon]>` (arrives at
31
+ // runtime as `@[fx.convertsicon]`).
29
32
  let attrName = evalInScope(matchInner, effectiveState, element);
30
33
 
31
- // If exact match failed and expression is a simple property (no dots/brackets)
32
- if (!attrName && !matchInner.includes('.') && !matchInner.includes('[')) {
33
- // Find the property with case-insensitive match
34
- const keys = Object.keys(effectiveState);
35
- const matchingKey = keys.find(k => k.toLowerCase() === matchInner.toLowerCase());
36
- if (matchingKey) {
37
- attrName = effectiveState[matchingKey];
38
- }
34
+ if (!attrName) {
35
+ attrName = resolveCaseInsensitivePath(effectiveState, matchInner);
39
36
  }
40
37
 
41
38
  // Track multiple name bindings per element (need a map of binding -> evaluated attr)
@@ -87,17 +84,24 @@ export default (affected, state, manifest = {}, oldState = {}) => {
87
84
  // Attribute: Truthful DOM representation, enables compiler snapshots via outerHTML
88
85
  const expr = isPureBinding[1];
89
86
  const value = evalInScope(expr, effectiveState, element);
90
- element[attrName] = value;
87
+ if (element[attrName] !== value) element[attrName] = value;
91
88
  if (value !== undefined && value !== null) {
92
- element.setAttribute(attrName, String(value));
89
+ const str = String(value);
90
+ if (element.getAttribute(attrName) !== str) element.setAttribute(attrName, str);
93
91
  }
94
92
  } else if (!isValueAttr && isPureBinding) {
95
- // Boolean-like attributes: add or remove based on truthiness
93
+ // Boolean-like attributes: add or remove based on truthiness.
94
+ // Compare both presence AND value — initial hydration starts with
95
+ // the raw `@[...]` binding text as the attribute value, so
96
+ // `hasAttribute` alone isn't enough to know the canonical state is
97
+ // already set.
96
98
  const expr = isPureBinding[1];
97
99
  const value = evalInScope(expr, effectiveState, element);
98
100
  if (value) {
99
- element.setAttribute(attrName, '');
100
- } else {
101
+ if (element.getAttribute(attrName) !== '') {
102
+ element.setAttribute(attrName, '');
103
+ }
104
+ } else if (element.hasAttribute(attrName)) {
101
105
  element.removeAttribute(attrName);
102
106
  }
103
107
  } else {
@@ -105,7 +109,9 @@ export default (affected, state, manifest = {}, oldState = {}) => {
105
109
  const newValue = attrValue.replace(BINDING_REGEX, (_, expr) => {
106
110
  return evalInScope(expr, effectiveState, element);
107
111
  });
108
- element.setAttribute(attrName, newValue);
112
+ if (element.getAttribute(attrName) !== newValue) {
113
+ element.setAttribute(attrName, newValue);
114
+ }
109
115
  }
110
116
  } catch (e) {}
111
117
  return;
@@ -128,11 +134,19 @@ export default (affected, state, manifest = {}, oldState = {}) => {
128
134
  });
129
135
 
130
136
  // If we have a direct reference to the text node, update it specifically
131
- // This prevents wiping child elements when parent has both text and element children
137
+ // This prevents wiping child elements when parent has both text and element children.
138
+ // Skip the write when the value is already correct — the browser would repaint
139
+ // (and any in-flight CSS transition on the row would jitter) even when no value
140
+ // actually changed. Reactivity coverage is unchanged: the only state changes that
141
+ // hit this path either produce a new value (still applied) or don't (now no-op).
142
+ // Skip the write when the value is already correct — the browser would
143
+ // repaint (and any in-flight CSS transition would jitter) even when no
144
+ // value actually changed. Reactivity coverage is unchanged: state changes
145
+ // that produce a new value still apply; state changes that don't are now
146
+ // proper no-ops at the DOM layer.
132
147
  if (textNode && textNode.nodeType === 3) {
133
- textNode.textContent = toReplace;
134
- } else {
135
- // Fallback: element has no children or is just a text container
148
+ if (textNode.textContent !== toReplace) textNode.textContent = toReplace;
149
+ } else if (element.textContent !== toReplace) {
136
150
  element.textContent = toReplace;
137
151
  }
138
152
  } catch (e) {}
package/runtime/index.js CHANGED
@@ -4,8 +4,9 @@ import createManifest from './manifest.js';
4
4
  import hydrate from './hydrate.js';
5
5
  import affected from './affected.js';
6
6
  import { deepMerge, hash } from './utils.js';
7
- import { renderAllIterations, setRenderAllConditionals } from './iterate.js';
7
+ import { renderAllIterations, setRenderAllConditionals, releaseOrphanedIterationProps } from './iterate.js';
8
8
  import { renderAllConditionals, branchNodeRegistry, managedNodes } from './conditionals.js';
9
+ import { installScopeResolver } from './loop-scope.js';
9
10
  import {
10
11
  NON_REACTIVE_ELEMENTS,
11
12
  PHASE_ATTACH,
@@ -21,7 +22,7 @@ import {
21
22
  PHASE_READY,
22
23
  DEHYDRATE_CLASS_OR_ATTR,
23
24
  } from './constants.js';
24
- import { processComponent, abortComponentFetch, collectComponentIds, releaseOrphanedComponentState } from './component.js';
25
+ import { processComponent, abortComponentFetch, collectComponentIds, releaseOrphanedComponentState, renderComponentTemplate } from './component.js';
25
26
  import { debugLog } from './debug.js';
26
27
  import { shouldCleanup, cleanup } from './cleanup.js';
27
28
  import { reconcile } from './reconcile.js';
@@ -327,6 +328,10 @@ let previousState = {};
327
328
  const main = (s, config = {}, stringSelector = '') => {
328
329
  const debug = !!config?.debug;
329
330
  const verbose = !!config?.verbose;
331
+ globalThis.__vibeDebug = debug;
332
+
333
+ // Expose the global `$scope` resolver used by loop-scoped `on*` handlers.
334
+ installScopeResolver();
330
335
 
331
336
  // Detect if running in compiler's headless browser
332
337
  // When true: skip cleanup to preserve [vibe] attribute in compiled HTML
@@ -550,7 +555,17 @@ const main = (s, config = {}, stringSelector = '') => {
550
555
  // while still detecting binding changes inside iteration instances.
551
556
  const currentState = { ...previousState };
552
557
  for (const prop of changedProps) {
553
- currentState[prop] = extractPlainValue($[prop]);
558
+ // Distinguish "set to undefined" (key still present in $) from "deleted"
559
+ // (key absent from $). For deletions, removing from currentState matches
560
+ // the live proxy's shape — otherwise downstream consumers that pass
561
+ // currentState as `$` to evalInScope (e.g. iterations that re-render via
562
+ // bindings reading the root state) would see the deleted key as a phantom
563
+ // own-property with value `undefined`.
564
+ if (prop in $) {
565
+ currentState[prop] = extractPlainValue($[prop]);
566
+ } else {
567
+ delete currentState[prop];
568
+ }
554
569
  }
555
570
 
556
571
  // Find what changed (compare previousState vs currentState)
@@ -608,6 +623,11 @@ const main = (s, config = {}, stringSelector = '') => {
608
623
  });
609
624
 
610
625
  // Add hook subscription method (non-enumerable so it won't be spread/cloned with state)
626
+ // `configurable: true` lets a component script's scoped `$` Proxy (built in
627
+ // component.js) legally return a wrapped `.on` that auto-registers cleanup
628
+ // — the Proxy invariant rejects overriding non-configurable + non-writable
629
+ // data properties. Enumerable stays false so `on` doesn't leak into state
630
+ // snapshots or Object.keys($).
611
631
  Object.defineProperty($, 'on', {
612
632
  value: (event, callback) => {
613
633
  if (hooks[event]) {
@@ -616,6 +636,7 @@ const main = (s, config = {}, stringSelector = '') => {
616
636
  return () => (hooks[event] = hooks[event].filter((cb) => cb !== callback));
617
637
  },
618
638
  enumerable: false,
639
+ configurable: true,
619
640
  });
620
641
 
621
642
  // Promise that resolves after the ready hook fires and all ready callbacks
@@ -634,6 +655,16 @@ const main = (s, config = {}, stringSelector = '') => {
634
655
  enumerable: false,
635
656
  });
636
657
 
658
+ // Pure-render path for surgical component HMR. Given raw component template
659
+ // HTML, callsite props, slot HTML, and existing componentIds, returns the
660
+ // processed HTML string the plugin's HMR handler can hand to $.reconcile.
661
+ // Scripts are NOT executed — callers use this only when they've verified
662
+ // script contents haven't changed (so registered state is still valid).
663
+ Object.defineProperty($, 'renderComponent', {
664
+ value: renderComponentTemplate,
665
+ enumerable: false,
666
+ });
667
+
637
668
  // Initial hydration - pass plain values so iteration can do reference comparison
638
669
  const initialState = extractPlainValue($);
639
670
  const affectedElements = affected(parsedTree, initialState, initialState);
@@ -850,6 +881,19 @@ const main = (s, config = {}, stringSelector = '') => {
850
881
  iteratedCount += counts.iteratedCount;
851
882
  evaluatedCount += counts.evaluatedCount;
852
883
 
884
+ // For inlined `<component>` wrappers belonging to an iteration row
885
+ // (marked by component.js's resolveIterationComponentProps transfer),
886
+ // stash the parsed tree on the wrapper. processCoreLoop has just run
887
+ // hydrate + renderAllConditionals + renderAllIterations on it, so
888
+ // `parsedNode` carries live `runtime.activeInstance` /
889
+ // `runtime.instances` data — exactly what `affected.js` needs to walk
890
+ // into branch / row content. Iterate.js's update path consumes this
891
+ // tree to re-hydrate bindings inside the inlined component on each
892
+ // row-scope change without rebuilding the wrapper's DOM.
893
+ if (node.nodeType === 1 && node._vibeIterPropExprs) {
894
+ node._vibeIterTree = parsedNode;
895
+ }
896
+
853
897
  // Count elements vs nodes separately
854
898
  if (node.nodeName.startsWith('#')) {
855
899
  addedNodes++;
@@ -864,6 +908,9 @@ const main = (s, config = {}, stringSelector = '') => {
864
908
 
865
909
  // CLEANUP OF CURRENT STATE
866
910
  releaseOrphanedComponentState(removedComponentIds);
911
+ mutations.forEach(({ removedNodes: removedNodesList }) => {
912
+ releaseOrphanedIterationProps(removedNodesList);
913
+ });
867
914
 
868
915
  // Fire hooks once after all mutations are processed (not per-node)
869
916
  if (hadChanges) {