@ape-egg/vibe 1.1.2 → 1.2.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,27 +3,20 @@ import { updateConditional } from './conditionals.js';
3
3
  import { VALUE_ATTRS, DOM_PROPERTIES, BINDING_REGEX, PURE_BINDING_REGEX } from './constants.js';
4
4
  import { evalInScope } from './utils.js';
5
5
 
6
- // Keep track of old state for diffing
7
- let previousState = {};
8
-
9
- export const setPreviousState = (state) => {
10
- previousState = { ...state };
11
- };
12
-
13
- export default (affected, state, manifest = {}) => {
6
+ export default (affected, state, manifest = {}, oldState = {}) => {
14
7
  affected.forEach((aff) => {
15
8
  // Use scoped state if provided (from iteration instances)
16
9
  const effectiveState = aff.scopedState || state;
17
10
 
18
11
  // Handle iteration updates
19
12
  if (aff.type === 'iteration') {
20
- updateIteration(aff.node, state, previousState, manifest);
13
+ updateIteration(aff.node, state, oldState, manifest);
21
14
  return;
22
15
  }
23
16
 
24
17
  // Handle conditional updates
25
18
  if (aff.type === 'conditional') {
26
- updateConditional(aff.node, state, previousState, manifest);
19
+ updateConditional(aff.node, state, oldState, manifest);
27
20
  return;
28
21
  }
29
22
 
@@ -33,7 +26,7 @@ export default (affected, state, manifest = {}) => {
33
26
  try {
34
27
  // HTML lowercases attribute names, so we need case-insensitive lookup
35
28
  // Try exact match first, then try finding a case-insensitive match
36
- let attrName = evalInScope(matchInner, effectiveState);
29
+ let attrName = evalInScope(matchInner, effectiveState, element);
37
30
 
38
31
  // If exact match failed and expression is a simple property (no dots/brackets)
39
32
  if (!attrName && !matchInner.includes('.') && !matchInner.includes('[')) {
@@ -91,12 +84,12 @@ export default (affected, state, manifest = {}) => {
91
84
  if (isDomProperty && isPureBinding) {
92
85
  // For DOM properties like value, set the property directly
93
86
  const expr = isPureBinding[1];
94
- const value = evalInScope(expr, effectiveState);
87
+ const value = evalInScope(expr, effectiveState, element);
95
88
  element[attrName] = value;
96
89
  } else if (!isValueAttr && isPureBinding) {
97
90
  // Boolean-like attributes: add or remove based on truthiness
98
91
  const expr = isPureBinding[1];
99
- const value = evalInScope(expr, effectiveState);
92
+ const value = evalInScope(expr, effectiveState, element);
100
93
  if (value) {
101
94
  element.setAttribute(attrName, '');
102
95
  } else {
@@ -105,7 +98,7 @@ export default (affected, state, manifest = {}) => {
105
98
  } else {
106
99
  // Value attribute - replace bindings with values
107
100
  const newValue = attrValue.replace(BINDING_REGEX, (_, expr) => {
108
- return evalInScope(expr, effectiveState);
101
+ return evalInScope(expr, effectiveState, element);
109
102
  });
110
103
  element.setAttribute(attrName, newValue);
111
104
  }
@@ -114,12 +107,12 @@ export default (affected, state, manifest = {}) => {
114
107
  }
115
108
 
116
109
  // Handle regular element updates
117
- const { matches, matchOuter, matchInner, input, element } = aff;
110
+ const { matches, matchOuter, matchInner, input, element, textNode } = aff;
118
111
 
119
112
  // This prevents undefined store properties to throw an error
120
113
  try {
121
114
  // Evaluate the expression with state as context
122
- const evaluated = evalInScope(matchInner, effectiveState);
115
+ const evaluated = evalInScope(matchInner, effectiveState, element);
123
116
 
124
117
  const toReplace = input.replaceAll(matchOuter, evaluated).trim();
125
118
 
@@ -129,10 +122,14 @@ export default (affected, state, manifest = {}) => {
129
122
  }
130
123
  });
131
124
 
132
- element.textContent = toReplace;
125
+ // If we have a direct reference to the text node, update it specifically
126
+ // This prevents wiping child elements when parent has both text and element children
127
+ if (textNode && textNode.nodeType === 3) {
128
+ textNode.textContent = toReplace;
129
+ } else {
130
+ // Fallback: element has no children or is just a text container
131
+ element.textContent = toReplace;
132
+ }
133
133
  } catch (e) {}
134
134
  });
135
-
136
- // Update previous state for next diff
137
- setPreviousState(state);
138
135
  };
package/runtime/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import state from './state.js';
2
2
  import parse from './parse.js';
3
3
  import createManifest from './manifest.js';
4
- import hydrate, { setPreviousState } from './hydrate.js';
4
+ import hydrate from './hydrate.js';
5
5
  import affected from './affected.js';
6
6
  import { deepMerge, hash } from './utils.js';
7
7
  import { renderAllIterations, setRenderAllConditionals } from './iterate.js';
@@ -22,23 +22,40 @@ import {
22
22
  import { processComponent, abortComponentFetch } from './component.js';
23
23
  import { debugLog } from './debug.js';
24
24
  import { shouldCleanup, cleanup } from './cleanup.js';
25
+ import { generateComponentId, executeComponentScript } from './component-state.js';
25
26
 
26
27
  // Wire up cross-module dependency after all modules are loaded
27
28
  setRenderAllConditionals(renderAllConditionals);
28
29
 
29
- // Check if node itself or any ancestor is non-reactive or dehydrated
30
- const isNonReactiveOrInside = (node) => {
30
+ // Check if node should be processed by Vibe
31
+ const shouldProcessNode = (node) => {
32
+ // Only process element nodes
33
+ if (node.nodeType !== 1) return false;
34
+
35
+ // Fast check first: skip nodes without Vibe syntax (cheapest check)
36
+ const html = node.outerHTML;
37
+ if (
38
+ !html.includes('@[') &&
39
+ !html.includes('<!-- each') &&
40
+ !html.includes('<!-- if') &&
41
+ !html.includes('<component')
42
+ ) {
43
+ return false;
44
+ }
45
+
46
+ // Node has Vibe syntax - now check if it's in a non-reactive context
31
47
  let current = node;
32
48
  while (current && current !== document.body) {
33
49
  if (NON_REACTIVE_ELEMENTS.includes(current.nodeName)) {
34
- return true;
50
+ return false;
35
51
  }
36
52
  if (current.hasAttribute?.('dehydrate')) {
37
- return true;
53
+ return false;
38
54
  }
39
55
  current = current.parentElement;
40
56
  }
41
- return false;
57
+
58
+ return true;
42
59
  };
43
60
 
44
61
  // Navigate tree using dot notation (handles .children at each level)
@@ -90,10 +107,11 @@ const processCoreLoop = (node, parsedNode, state, manifest, isNewNode, debug) =>
90
107
 
91
108
  // 2. Hydrate (replace @[...] bindings)
92
109
  // Use empty object as "previous state" for new nodes so all bindings are affected
93
- const affectedElements = affected(parsedNode, isNewNode ? {} : previousState, state);
110
+ const oldStateForAffected = isNewNode ? {} : previousState;
111
+ const affectedElements = affected(parsedNode, oldStateForAffected, state);
94
112
  if (affectedElements.length > 0) {
95
113
  hydratedCount = affectedElements.length;
96
- hydrate(affectedElements, state, manifest);
114
+ hydrate(affectedElements, state, manifest, oldStateForAffected);
97
115
  }
98
116
 
99
117
  // 3. Conditionals (evaluate <!-- if -->)
@@ -139,6 +157,45 @@ const main = (s, attrName = 'vibe', config = {}) => {
139
157
 
140
158
  debugLog(PHASE_ATTACH, `Vibe attached to`, debug, 0, rootElement);
141
159
 
160
+ // Process inline component scripts FIRST (before parse)
161
+ // This sets componentIds so parse can rewrite event handlers
162
+ const componentScripts = rootElement.querySelectorAll('script[type="component"]');
163
+
164
+ if (componentScripts.length > 0) {
165
+ componentScripts.forEach((script) => {
166
+ // Skip if already processed (e.g., by auto.js)
167
+ if (script.hasAttribute('data-vibe-component-id')) return;
168
+
169
+ const scriptContent = script.textContent?.trim() || '';
170
+ if (!scriptContent) return;
171
+
172
+ const componentId = generateComponentId();
173
+
174
+ // Store componentId on script element BEFORE parsing
175
+ script.setAttribute('data-vibe-component-id', componentId);
176
+
177
+ // Tag all following sibling elements with this component ID (shallow)
178
+ // Stop at next component script or end of siblings
179
+ let sibling = script.nextElementSibling;
180
+ while (sibling) {
181
+ // Stop if we hit another component script
182
+ if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'component') {
183
+ break;
184
+ }
185
+ // Tag only the sibling (descendants will use closest() to find it)
186
+ sibling.setAttribute('data-vibe-component-id', componentId);
187
+ sibling = sibling.nextElementSibling;
188
+ }
189
+
190
+ // Execute component script to get state
191
+ const componentState = executeComponentScript(scriptContent);
192
+
193
+ // Add component state to initial state
194
+ s[componentId] = componentState;
195
+ });
196
+ }
197
+
198
+ // Now parse (event handlers will be rewritten correctly)
142
199
  let parsedTree = parse(rootElement);
143
200
  let manifest = createManifest(parsedTree);
144
201
 
@@ -171,19 +228,33 @@ const main = (s, attrName = 'vibe', config = {}) => {
171
228
  afterDomMutation: [],
172
229
  };
173
230
 
231
+ // Extract plain values from proxy (removes proxy wrappers)
232
+ const extractPlainValue = (obj) => {
233
+ if (obj === null || typeof obj !== 'object') return obj;
234
+ if (Array.isArray(obj)) return obj.map(extractPlainValue);
235
+ const plain = {};
236
+ for (const key in obj) {
237
+ if (obj.hasOwnProperty(key)) {
238
+ plain[key] = extractPlainValue(obj[key]);
239
+ }
240
+ }
241
+ return plain;
242
+ };
243
+
174
244
  // Observer reference and callback - defined here so state handler can access processMutations
175
245
  let observer = null;
176
246
  let processMutations = null;
177
247
 
178
- const $ = state(s, (newState) => {
179
- const mergedState = deepMerge($, newState);
248
+ const $ = state(s, (newState, oldState) => {
249
+ // Extract current state (after mutation)
250
+ const currentState = extractPlainValue($);
251
+ const changedProp = Object.keys(newState)[0];
180
252
 
181
- // Find what changed
182
- const affectedElements = affected(parsedTree, previousState, mergedState);
253
+ // Find what changed (compare previousState vs currentState)
254
+ const affectedElements = affected(parsedTree, previousState, currentState);
183
255
 
184
256
  if (affectedElements.length > 0) {
185
- const changedKeys = Object.keys(newState).join(', ');
186
- debugLog(PHASE_UPDATE, `state changed (${changedKeys})`, debug);
257
+ debugLog(PHASE_UPDATE, `state changed (${changedProp})`, debug);
187
258
 
188
259
  // Capture pending mutations before disconnecting (takeRecords clears the queue)
189
260
  let pendingMutations = [];
@@ -194,11 +265,11 @@ const main = (s, attrName = 'vibe', config = {}) => {
194
265
 
195
266
  // Run core loop on the full tree (hydrate will use affected list)
196
267
  // isNewNode = false because this is a state update, not a DOM mutation
197
- hydrate(affectedElements, mergedState, manifest);
268
+ hydrate(affectedElements, currentState, manifest, previousState);
198
269
 
199
270
  // After hydrate, check for conditional/iteration changes
200
- const conditionalCount = renderAllConditionals(parsedTree, mergedState, manifest);
201
- const iterationCount = renderAllIterations(parsedTree, mergedState, manifest);
271
+ const conditionalCount = renderAllConditionals(parsedTree, currentState, manifest);
272
+ const iterationCount = renderAllIterations(parsedTree, currentState, manifest);
202
273
 
203
274
  if (observer) {
204
275
  observer.observe(rootElement, {
@@ -215,22 +286,10 @@ const main = (s, attrName = 'vibe', config = {}) => {
215
286
  }
216
287
  }
217
288
 
218
- // Deep clone that handles proxies (structuredClone fails on proxy objects)
219
- const deepClone = (obj) => {
220
- if (obj === null || typeof obj !== 'object') return obj;
221
- if (Array.isArray(obj)) return obj.map(deepClone);
222
- const cloned = {};
223
- for (const key in obj) {
224
- if (obj.hasOwnProperty(key)) {
225
- cloned[key] = deepClone(obj[key]);
226
- }
227
- }
228
- return cloned;
229
- };
230
-
231
- const prev = deepClone(previousState);
232
- previousState = { ...$, ...newState };
233
- hooks.afterUpdate.forEach((callback) => callback(deepClone({ ...$ }), prev));
289
+ // Store previous state for hooks (currentState is already plain, no need to clone)
290
+ const prev = previousState;
291
+ previousState = currentState;
292
+ hooks.afterUpdate.forEach((callback) => callback(currentState, prev));
234
293
  });
235
294
 
236
295
  // Add hook subscription method (non-enumerable so it won't be spread/cloned with state)
@@ -244,8 +303,9 @@ const main = (s, attrName = 'vibe', config = {}) => {
244
303
  enumerable: false,
245
304
  });
246
305
 
247
- // Initial hydration
248
- const affectedElements = affected(parsedTree, $, $);
306
+ // Initial hydration - pass plain values so iteration can do reference comparison
307
+ const initialState = extractPlainValue($);
308
+ const affectedElements = affected(parsedTree, initialState, initialState);
249
309
 
250
310
  debugLog(
251
311
  PHASE_HYDRATE,
@@ -257,14 +317,13 @@ const main = (s, attrName = 'vibe', config = {}) => {
257
317
  debug,
258
318
  );
259
319
 
260
- hydrate(affectedElements, $, manifest);
261
-
320
+ // Hydrate with proxy $ so DOM bindings work
321
+ // Pass initialState as oldState for iterations (won't actually update, just initial render)
322
+ hydrate(affectedElements, $, manifest, initialState);
262
323
 
263
324
  // Render all iterations and conditionals after initial hydration
264
- setPreviousState($);
265
- previousState = { ...$ };
266
-
267
- const iterationCount = renderAllIterations(parsedTree, $, manifest);
325
+ // Use initialState (plain values) for iteration rendering so reference comparison works
326
+ const iterationCount = renderAllIterations(parsedTree, initialState, manifest);
268
327
  if (iterationCount > 0)
269
328
  debugLog(
270
329
  PHASE_ITERATE,
@@ -288,6 +347,9 @@ const main = (s, attrName = 'vibe', config = {}) => {
288
347
  debug,
289
348
  );
290
349
 
350
+ // After all initial rendering, capture a clean snapshot for comparison
351
+ previousState = extractPlainValue($);
352
+
291
353
  // Define observer callback as named function so we can call it manually for pending mutations
292
354
  processMutations = (mutations) => {
293
355
  // Early exit if no mutations to process (common case)
@@ -353,8 +415,8 @@ const main = (s, attrName = 'vibe', config = {}) => {
353
415
  });
354
416
 
355
417
  addedNodesList.forEach((node) => {
356
- // Skip if node itself or any ancestor is non-reactive
357
- if (isNonReactiveOrInside(node)) {
418
+ // Skip nodes that aren't element nodes, are non-reactive, or lack Vibe syntax
419
+ if (!shouldProcessNode(node)) {
358
420
  return;
359
421
  }
360
422
 
@@ -539,12 +601,16 @@ const main = (s, attrName = 'vibe', config = {}) => {
539
601
  ...config,
540
602
  _forceSync: true,
541
603
  _observer: observer,
542
- _processMutations: processMutations
604
+ _processMutations: processMutations,
543
605
  };
544
- processComponent(rootElement, () => {
545
- // When all components are done, run cleanup check
546
- checkCleanup();
547
- }, componentConfig);
606
+ processComponent(
607
+ rootElement,
608
+ () => {
609
+ // When all components are done, run cleanup check
610
+ checkCleanup();
611
+ },
612
+ componentConfig,
613
+ );
548
614
  }
549
615
  };
550
616
 
@@ -596,7 +662,7 @@ const main = (s, attrName = 'vibe', config = {}) => {
596
662
  ...config,
597
663
  _forceSync: true,
598
664
  _observer: observer,
599
- _processMutations: processMutations
665
+ _processMutations: processMutations,
600
666
  };
601
667
 
602
668
  processComponent(
@@ -2,6 +2,7 @@ import parse from './parse.js';
2
2
  import affected from './affected.js';
3
3
  import hydrate from './hydrate.js';
4
4
  import { resolvePath, getItemKey, computeDiff } from './iteration-utils.js';
5
+ import { resolveThisPath } from './utils.js';
5
6
 
6
7
  // Fast path for iteration rendering (opt-in via window.__VIBE_FAST_ITERATION__)
7
8
  // See: _vibe-compiled-iteration-batch.js for implementation details
@@ -153,7 +154,7 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
153
154
  }
154
155
 
155
156
  if (tree) {
156
- const affectedElements = affected(tree, {}, scopedState);
157
+ const affectedElements = affected(tree, {}, scopedState, [], scopedState);
157
158
  hydrate(affectedElements, scopedState);
158
159
  }
159
160
 
@@ -278,7 +279,10 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
278
279
 
279
280
  const parent = startComment.parentNode;
280
281
 
281
- const array = resolvePath(state, arrayPath);
282
+ // Handle this.property for component-scoped arrays
283
+ const resolvedArrayPath = resolveThisPath(arrayPath, startComment.parentElement);
284
+
285
+ const array = resolvePath(state, resolvedArrayPath);
282
286
  if (!Array.isArray(array) || array.length === 0) {
283
287
  iterationNode.runtime.instances = [];
284
288
  return;
@@ -327,8 +331,12 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
327
331
  if (!iterationNode.runtime.instances || !iterationNode.runtime.templateRemoved) return;
328
332
 
329
333
  const { arrayPath, template, startComment, endComment } = iterationNode.meta;
330
- const oldArray = resolvePath(oldState, arrayPath) || [];
331
- const newArray = resolvePath(newState, arrayPath) || [];
334
+
335
+ // Handle this.property for component-scoped arrays
336
+ const resolvedArrayPath = resolveThisPath(arrayPath, startComment.parentElement);
337
+
338
+ const oldArray = resolvePath(oldState, resolvedArrayPath) || [];
339
+ const newArray = resolvePath(newState, resolvedArrayPath) || [];
332
340
 
333
341
  // Fast path: opt-in via window.__VIBE_FAST_ITERATION__ (preview of Vibe Compiled)
334
342
  // Use for bulk operations (large arrays or empty→full transitions)
package/runtime/parse.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  BINDING_REGEX,
6
6
  ITERATION_REGEX,
7
7
  CONDITIONAL_REGEX,
8
+ DOM_ELEMENT_PROPERTIES,
8
9
  } from './constants.js';
9
10
 
10
11
  const parseHTML = (children, rootKey = undefined) =>
@@ -185,6 +186,11 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
185
186
  const name = nodeName.startsWith('#') ? nodeName.slice(1) : nodeName;
186
187
  const nodeIdentifier = `${name}_${i}`.toLowerCase();
187
188
 
189
+ // For text nodes, use parent element (text nodes can't have attributes)
190
+ const isTextNode = nodeName === '#text';
191
+ const elementForBindings = isTextNode ? element.parentElement : element;
192
+ const textNodeRef = isTextNode ? element : null; // Store reference to actual text node
193
+
188
194
  // Check for attribute bindings
189
195
  const attributes = {};
190
196
  const nameBindings = [];
@@ -200,6 +206,18 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
200
206
  continue; // Don't process as regular attribute
201
207
  }
202
208
 
209
+ // Rewrite event handlers with this. to use component state
210
+ if (attr.name.startsWith('on') && attr.value.includes('this.')) {
211
+ const componentId = findComponentIdForElement(element);
212
+ if (componentId) {
213
+ // Rewrite this.property to $['componentId'].property, but skip DOM properties
214
+ const rewritten = attr.value.replace(/\bthis\.(\w+)/g, (match, prop) => {
215
+ return DOM_ELEMENT_PROPERTIES.has(prop) ? match : `$['${componentId}'].${prop}`;
216
+ });
217
+ element.setAttribute(attr.name, rewritten);
218
+ }
219
+ }
220
+
203
221
  // Check if attribute value contains binding
204
222
  BINDING_REGEX.lastIndex = 0;
205
223
  if (BINDING_REGEX.test(attr.value)) {
@@ -207,6 +225,32 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
207
225
  }
208
226
  }
209
227
  }
228
+
229
+ // Helper to find component ID for an element
230
+ function findComponentIdForElement(element) {
231
+ if (!element || !element.parentElement) return null;
232
+
233
+ let current = element;
234
+
235
+ while (current && current !== document.body) {
236
+ // Check previous siblings for script[data-vibe-component-id]
237
+ let sibling = current.previousElementSibling;
238
+ while (sibling) {
239
+ if (
240
+ sibling.tagName === 'SCRIPT' &&
241
+ sibling.getAttribute('type') === 'component' &&
242
+ sibling.hasAttribute('data-vibe-component-id')
243
+ ) {
244
+ return sibling.getAttribute('data-vibe-component-id');
245
+ }
246
+ sibling = sibling.previousElementSibling;
247
+ }
248
+
249
+ current = current.parentElement;
250
+ }
251
+
252
+ return null;
253
+ }
210
254
  const hasAttributeBindings = Object.keys(attributes).length > 0;
211
255
  const hasNameBindings = nameBindings.length > 0;
212
256
 
@@ -218,18 +262,20 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
218
262
 
219
263
  result[rootKey || nodeIdentifier] = {
220
264
  parsed,
221
- element,
265
+ element: elementForBindings,
222
266
  children: recursive(iteratableChildren, undefined, new Set(), stats),
223
267
  ...(hasAttributeBindings && { attributes }),
224
268
  ...(hasNameBindings && { nameBindings }),
269
+ ...(textNodeRef && { textNode: textNodeRef }),
225
270
  };
226
271
  } else {
227
272
  result[nodeIdentifier] = {
228
273
  parsed: innerHTML || textContent,
229
- element,
274
+ element: elementForBindings,
230
275
  children: {},
231
276
  ...(hasAttributeBindings && { attributes }),
232
277
  ...(hasNameBindings && { nameBindings }),
278
+ ...(textNodeRef && { textNode: textNodeRef }),
233
279
  };
234
280
  }
235
281
  }
@@ -0,0 +1,70 @@
1
+ // Scope resolution for component state
2
+
3
+ /**
4
+ * Find the component ID that owns this element
5
+ * Walks up DOM to find nearest script[data-vibe-component-id]
6
+ * @param {Element} element - DOM element to find component for
7
+ * @returns {string|null} - Component ID or null if not in component scope
8
+ */
9
+ export const findComponentId = (element) => {
10
+ let current = element;
11
+
12
+ while (current && current !== document.body) {
13
+ // Check if this element has component ID
14
+ if (current.hasAttribute && current.hasAttribute('data-vibe-component-id')) {
15
+ return current.getAttribute('data-vibe-component-id');
16
+ }
17
+
18
+ // Check previous siblings for script[data-vibe-component-id]
19
+ let sibling = current.previousElementSibling;
20
+ while (sibling) {
21
+ if (
22
+ sibling.tagName === 'SCRIPT' &&
23
+ sibling.getAttribute('type') === 'component' &&
24
+ sibling.hasAttribute('data-vibe-component-id')
25
+ ) {
26
+ return sibling.getAttribute('data-vibe-component-id');
27
+ }
28
+ sibling = sibling.previousElementSibling;
29
+ }
30
+
31
+ current = current.parentElement;
32
+ }
33
+
34
+ return null;
35
+ };
36
+
37
+ /**
38
+ * Resolve a property path with component scope fallback
39
+ * Checks component state first, then global state
40
+ * @param {string} path - Property path (e.g., "count" or "user.name")
41
+ * @param {Element} element - DOM element for context
42
+ * @param {object} globalState - Global $ object
43
+ * @returns {*} - Resolved value
44
+ */
45
+ export const resolveWithScope = (path, element, globalState) => {
46
+ const componentId = findComponentId(element);
47
+
48
+ if (componentId && globalState[componentId]) {
49
+ // Check component state first
50
+ const componentState = globalState[componentId];
51
+ const value = resolvePath(path, componentState);
52
+
53
+ if (value !== undefined) {
54
+ return value;
55
+ }
56
+ }
57
+
58
+ // Fallback to global state
59
+ return resolvePath(path, globalState);
60
+ };
61
+
62
+ /**
63
+ * Resolve a dot-notation path in an object
64
+ * @param {string} path - Property path
65
+ * @param {object} obj - Object to resolve path in
66
+ * @returns {*} - Resolved value or undefined
67
+ */
68
+ const resolvePath = (path, obj) => {
69
+ return path.split('.').reduce((current, key) => current?.[key], obj);
70
+ };
package/runtime/state.js CHANGED
@@ -16,17 +16,22 @@ const createDeepProxy = (target, rerender, rootState = null, rootProp = null) =>
16
16
  const proxy = new Proxy(target, {
17
17
  set(obj, prop, value) {
18
18
  const oldValue = obj[prop];
19
- const ref = Reflect.set(obj, prop, value);
20
19
 
21
20
  // Only trigger rerender if value actually changed
22
21
  if (oldValue !== value) {
23
- // If we're at root level, use the prop being set
24
- // If we're nested, use the root prop that contains this nested object
22
+ // Perform the mutation
23
+ const ref = Reflect.set(obj, prop, value);
24
+
25
+ // Trigger rerender with changed state
26
+ // If we're nested, use the root prop; otherwise use the prop itself
25
27
  const changedProp = rootProp || prop;
26
- rerender({ [changedProp]: rootState[changedProp] });
28
+ rerender({ [changedProp]: rootState[changedProp] }, null);
29
+
30
+ return ref;
27
31
  }
28
32
 
29
- return ref;
33
+ // No change, just set
34
+ return Reflect.set(obj, prop, value);
30
35
  },
31
36
 
32
37
  get(target, prop) {