@ape-egg/vibe 1.8.0 → 1.9.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.
- package/CHANGELOG.md +88 -0
- package/README.md +28 -0
- package/boot.js +5 -1
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/parser/html.rs +15 -15
- package/index.js +15 -0
- package/package.json +2 -2
- package/runtime/_vibe-compiled-iteration-batch.js +18 -9
- package/runtime/affected.js +36 -16
- package/runtime/component.js +154 -45
- package/runtime/conditionals.js +12 -1
- package/runtime/constants.js +5 -3
- package/runtime/index.js +77 -33
- package/runtime/iterate.js +270 -108
- package/runtime/manifest.js +2 -1
- package/runtime/parse.js +66 -80
- package/runtime/pre-compiled-manifest.js +38 -13
- package/runtime/reconcile.js +621 -0
- package/runtime/state.js +5 -2
- package/runtime/utils.js +74 -1
- package/vibe.css +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,93 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.9.0] - 2026-04-18
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`$.reconcile()` — subtree reconciliation API** (`runtime/reconcile.js`, new 621-line module) — reconcile a Vibe-managed subtree against new source HTML with a tag-aligned, two-pointer walker. Vibe-owned regions (iterations, conditionals, components, `<slot>` pairs) are treated as opaque; their internals stay state-driven. DOM identity, focus, and selection survive wherever a match is found.
|
|
8
|
+
- Exposed as `$.reconcile` (non-enumerable so it stays out of state snapshots)
|
|
9
|
+
- Test: `tests/e2e/reconcile.spec.js`
|
|
10
|
+
|
|
11
|
+
- **Component state cleanup on unmount** (`runtime/component.js`, `runtime/conditionals.js`, `runtime/index.js`, `runtime/manifest.js`) — when a `<component>` leaves the DOM (conditional branch swap, iteration key removal, etc.), its entry in `window.__vibeComponents[id]` and `window.$[id]` is evicted. `collectComponentIds` walks removed subtrees; `releaseOrphanedComponentState` verifies the DOM is fully gone before deleting state. Prevents state leaks from components that mount and unmount repeatedly.
|
|
12
|
+
- Test: `tests/e2e/component-state-cleanup.spec.js`
|
|
13
|
+
|
|
14
|
+
- **Arbitrary JS expressions in `<!-- each -->`** (`runtime/constants.js`) — `ITERATION_REGEX` relaxed from `[\w.\[\]]+` to `.+`. The array expression is now evaluated via `evalInScope`, so you can iterate over method calls, window globals, inline array literals, filters, and `Array.from(...)`:
|
|
15
|
+
```html
|
|
16
|
+
<!-- each items.filter(x => x.active) as item -->
|
|
17
|
+
<!-- each Array.from({length: 10}, (_, i) => i) as n -->
|
|
18
|
+
<!-- each window.fights as fight, i -->
|
|
19
|
+
```
|
|
20
|
+
- Test: `tests/e2e/each-expression.spec.js`
|
|
21
|
+
|
|
22
|
+
- **Missing-identifier safety in expressions** (`runtime/utils.js`) — `evalInScope` now wraps free identifiers that aren't state keys, reserved words, or known globals in `typeof` guards so they resolve to `undefined` instead of throwing `ReferenceError`. `@[!missingProp]` → `true`, `@[optional?.x]` → `undefined`. Uses `typeof` rather than a `var` hoist so real globals (app functions on `window` like `getLevelByExperience`) stay reachable.
|
|
23
|
+
|
|
24
|
+
- **Arrow function parameters and object-literal keys recognized** (`runtime/utils.js`) — identifier rewriting now skips `(a, b) =>` / `x =>` parameter lists and `{ key: value }` property names. Expressions like `items.map(x => x.name)` and `@[{ a: 1 }[k]]` no longer get turned into invalid JS by the missing-identifier pass.
|
|
25
|
+
|
|
26
|
+
- **Prop substitution covers directive comments and event handlers** (`runtime/component.js`) — prop identifiers now resolve inside `<!-- if propName -->`, `<!-- else if propName -->`, `<!-- each propName as item -->` and `onclick="$.propName = x"` in addition to `@[...]`. Writing `$.value = x` inside an event handler on a child component whose parent passed `value="@[email]"` updates `$.email` on the parent — natural two-way binding with no extra API.
|
|
27
|
+
- Tests: `tests/e2e/prop-in-directive.spec.js`, `tests/e2e/prop-in-expression.spec.js`, `tests/e2e/two-way-binding.spec.js`
|
|
28
|
+
|
|
29
|
+
- **Pre-boot `$.ready` promise** (`index.js`, `boot.js`) — the placeholder returned by `vibe()` before boot now exposes a `.ready` promise that chains to the real post-boot `$.ready`. Consumers that captured `window.$` early (tests, auto-initializers) can `await $.ready` without polling. New `chainInstanceReady` export links the two.
|
|
30
|
+
|
|
31
|
+
- **Async component scripts** (`runtime/component.js`) — `<script type="module">` blocks in components with `import` statements are rewritten to `await import()` and executed via `AsyncFunction`. Sync execution preserved when there are no imports (keeps boot timing unchanged). Component finalization waits for all async scripts before inlining.
|
|
32
|
+
- Tests: `tests/e2e/async-component-state.spec.js`, `tests/e2e/parallel-components.spec.js`
|
|
33
|
+
|
|
34
|
+
- **Promises never proxied as reactive state** (`runtime/state.js`) — proxy `get` trap now bails on `value instanceof Promise`. Wrapping a Promise violates the Proxy invariant when exposed as a non-writable property (e.g. `$.ready`) and makes no sense as reactive state.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **HTML-entity decoding in compiled iteration templates** (`runtime/_vibe-compiled-iteration-batch.js`) — when a template's attributes get serialized via `innerHTML`, the browser encodes `<`, `>`, `"`, `&`, `'`. The batch-function compiler was wrapping those encoded expressions directly in a template literal, producing invalid JS for bindings like `@[x > 0 ? 'a' : 'b']` when used inside an attribute. Entities are now decoded before interpolation.
|
|
39
|
+
|
|
40
|
+
- **`<!-- each -->` over `Array.from(...)` and other inline expressions** — previously failed silently because `ITERATION_REGEX` only accepted simple paths. See `ITERATION_REGEX` change above.
|
|
41
|
+
|
|
42
|
+
- **Attribute bindings on fetched `<component src>` wrappers were coerced to strings** (`runtime/parse.js`) — `captureAttributeBindings` now returns nulls for elements that are still `<component src>` / `<div class="component" src>` (pre-fetch), so raw prop values survive untouched for `processComponent` to substitute. Post-fetch wrappers (no `src`) capture attributes normally.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- **Iteration engine rework** (`runtime/iterate.js`, +378 lines over 1.8.1) — unified update path around tagged instance keys, bulk-replacement fast path when old/new arrays share no common keys (uses `Range.deleteContents` + single `innerHTML`), and DocumentFragment batching inside `renderIteration` so N inserts become one.
|
|
47
|
+
|
|
48
|
+
- **Debug scaffolding removed** (`runtime/index.js`) — an ad-hoc `[vibe-debug] rerender` `console.info` and the associated `window.__lastHits` / `window.__parsedTree` probes (leftover from an investigation) have been deleted.
|
|
49
|
+
|
|
50
|
+
### Documentation
|
|
51
|
+
|
|
52
|
+
- **README — "Future improvements to refactor"** — documents the string-based dependency-tracking approach used by `affected()` and its always-affected fallback semantics, and the HTML-lowercased attribute-name handling.
|
|
53
|
+
|
|
54
|
+
### Packaging
|
|
55
|
+
|
|
56
|
+
- **`@ape-egg/vite-plugin-vibe` published** — new sibling package providing a Vite plugin with HMR for Vibe pages and components. See its own `README.md` and `CHANGELOG.md`.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## [1.8.1] - 2026-04-12
|
|
61
|
+
|
|
62
|
+
### Added
|
|
63
|
+
|
|
64
|
+
- **`<slot>` boundary preserved in processed DOM** — component slot content is now wrapped in a `<slot>` element instead of being spliced in directly. Dev tools, HMR, and anything that needs to locate slot boundaries can query for `<slot>` instead of tracking comment pairs.
|
|
65
|
+
- `runtime/component.js` — `<slot></slot>` replaced with `<slot>${children}</slot>`
|
|
66
|
+
- `compiler/src/parser/html.rs` — compiler emits the same wrapping
|
|
67
|
+
- `vibe.css` — `slot, div.slot { display: contents }` so the wrapper is layout-transparent
|
|
68
|
+
|
|
69
|
+
### Fixed
|
|
70
|
+
|
|
71
|
+
- **Conditionals inside doubly-nested component slots lost their templates** — when a `<component src>` appeared inside another component's slot content, Vibe's parser ran `renderConditional` (which strips template nodes) before `processComponent` captured the slot content, so branch templates were gone by the time the nested component tried to use them.
|
|
72
|
+
- `runtime/index.js` — `processMutations` now captures `_vibeSlotContent` for all nested `<component src>` elements before parse/hydrate runs
|
|
73
|
+
- Test: `tests/e2e/conditional-in-nested-slot.spec.js`
|
|
74
|
+
|
|
75
|
+
- **Bindings with complex expressions re-hydrated on every state change** — `matchesKey` did prefix-only matching, so expressions like `@[Math.floor(coins / 100)]` couldn't be matched to any state key and hit the "always affected" fallback. A 250ms client clock would re-hydrate every such binding 4× per second.
|
|
76
|
+
- `runtime/affected.js` — `matchesKey` now also does word-boundary search so it finds `coins` as an identifier inside the expression
|
|
77
|
+
- Test: `tests/e2e/expression-dependency.spec.js`
|
|
78
|
+
|
|
79
|
+
- **Iteration instance comparisons used live Proxy as "old state"** — `scopedState` is a Proxy that reflects current global state, so comparing `state[k]` vs `newState[k]` showed both as the new value. Global state changes inside iterations weren't detected as changes.
|
|
80
|
+
- `runtime/affected.js` — iteration recursion now builds plain-object snapshots from previousState/currentState merged with scopedState's local vars
|
|
81
|
+
|
|
82
|
+
- **Name bindings re-hydrated on every state change** — HTML lowercases attribute names, so `<page @[pageName]>` becomes `@[pagename]` in the DOM. `matchesKey('pagename', 'pageName')` failed (case-sensitive), triggering the always-affected fallback and causing constant attribute flashing.
|
|
83
|
+
- `runtime/affected.js` — name binding dependency check now matches state keys case-insensitively, mirroring `hydrate.js`'s existing case-insensitive evaluation fallback
|
|
84
|
+
|
|
85
|
+
### Documentation
|
|
86
|
+
|
|
87
|
+
- `README.md` — "Future improvements to refactor" section documenting string-based dependency tracking limitations and HTML lowercase attribute handling
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
3
91
|
## [1.8.0] - 2026-04-10
|
|
4
92
|
|
|
5
93
|
### Added
|
package/README.md
CHANGED
|
@@ -525,3 +525,31 @@ Modern browsers with Proxy and MutationObserver support.
|
|
|
525
525
|
## License
|
|
526
526
|
|
|
527
527
|
ISC
|
|
528
|
+
|
|
529
|
+
---
|
|
530
|
+
|
|
531
|
+
## Future improvements to refactor
|
|
532
|
+
|
|
533
|
+
### Dependency tracking uses string matching, not AST
|
|
534
|
+
|
|
535
|
+
`affected()` determines which bindings to re-hydrate by string-matching expression text against state key names. When matching fails, a safety fallback marks the binding as affected on every state change.
|
|
536
|
+
|
|
537
|
+
- `runtime/affected.js:15` — `matchesKey` does exact/prefix match, then word-boundary search
|
|
538
|
+
- `runtime/affected.js:~122` — `shouldAffect = noMatch || ...` fallback for unmatched expressions
|
|
539
|
+
- `runtime/affected.js:~248` — same fallback for name bindings
|
|
540
|
+
|
|
541
|
+
Every time `matchesKey` can't prove an expression is unrelated to a state change, that binding re-hydrates. A proper AST-based dependency extraction at parse time would eliminate the fallback entirely: each binding stores its exact dependency set, and runtime just checks if any dep key changed.
|
|
542
|
+
|
|
543
|
+
### HTML lowercases attribute names — breaks name binding matching
|
|
544
|
+
|
|
545
|
+
The browser lowercases all attribute names during HTML parsing. `<page @[pageName]>` becomes `@[pagename]` in the DOM. Every value hydrated into an attribute NAME position loses its case. `@[myCoolPage]` → DOM stores `@[mycoolpage]`, causing comparison mismatches against camelCase state keys.
|
|
546
|
+
|
|
547
|
+
Handled today by case-insensitive fallbacks in two places:
|
|
548
|
+
- `runtime/hydrate.js:~32` — case-insensitive state key lookup when evaluating name bindings
|
|
549
|
+
- `runtime/affected.js:~235` — case-insensitive `matchesKey` wrapper for name bindings
|
|
550
|
+
|
|
551
|
+
Both are workarounds for HTML's behavior. A cleaner architecture would centralize the case-insensitive state key resolution into one helper, or normalize the name binding expression to canonical state-key case at first evaluation and cache it on the tree node.
|
|
552
|
+
|
|
553
|
+
### Related: value hydration into attribute values is case-preserving
|
|
554
|
+
|
|
555
|
+
Only attribute NAMES are lowercased by HTML, not values. So `title="@[pageName]"` and `@[pageName]` in text content preserve case and work without fallbacks — the issue is specific to name bindings.
|
package/boot.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Used by both index.js (global state) and component.js (component state)
|
|
3
3
|
|
|
4
4
|
import main from './runtime/index.js';
|
|
5
|
-
import { getPendingListeners } from './index.js';
|
|
5
|
+
import { getPendingListeners, chainInstanceReady } from './index.js';
|
|
6
6
|
|
|
7
7
|
let bootQueued = false;
|
|
8
8
|
let booted = false;
|
|
@@ -40,6 +40,10 @@ export const boot = () => {
|
|
|
40
40
|
// Boot with merged state
|
|
41
41
|
window.$ = main(mergedState, config, targetSelector);
|
|
42
42
|
|
|
43
|
+
// Forward real $.ready to the pre-boot placeholder's ready promise so
|
|
44
|
+
// any consumer that captured the placeholder can still await readiness.
|
|
45
|
+
chainInstanceReady(window.$.ready);
|
|
46
|
+
|
|
43
47
|
// Apply pending listeners from vibe instance
|
|
44
48
|
const pendingListeners = getPendingListeners();
|
|
45
49
|
if (pendingListeners) {
|
|
Binary file
|
|
Binary file
|
|
@@ -231,15 +231,15 @@ impl HtmlParser {
|
|
|
231
231
|
replacement = replacement.replace(&prop_binding, &prop_value);
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
-
// Replace <slot> tags
|
|
235
|
-
let
|
|
236
|
-
|
|
234
|
+
// Replace <slot> tags — wrap children in <slot> boundary, or remove if empty
|
|
235
|
+
let slot_wrapped = if slot_content.trim().is_empty() {
|
|
236
|
+
String::new()
|
|
237
237
|
} else {
|
|
238
|
-
|
|
238
|
+
format!("<slot>{}</slot>", slot_content)
|
|
239
239
|
};
|
|
240
|
-
replacement = replacement.replace("<slot></slot>",
|
|
241
|
-
replacement = replacement.replace("<slot/>",
|
|
242
|
-
replacement = replacement.replace("<slot />",
|
|
240
|
+
replacement = replacement.replace("<slot></slot>", &slot_wrapped);
|
|
241
|
+
replacement = replacement.replace("<slot/>", &slot_wrapped);
|
|
242
|
+
replacement = replacement.replace("<slot />", &slot_wrapped);
|
|
243
243
|
|
|
244
244
|
// Keep the wrapper for consistency with runtime (using generic <component> wrapper)
|
|
245
245
|
// No src attribute = wrapper won't be re-processed
|
|
@@ -371,10 +371,10 @@ impl HtmlParser {
|
|
|
371
371
|
replacement = replacement.replace(&prop_binding, prop_value);
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
-
let
|
|
375
|
-
replacement = replacement.replace("<slot></slot>",
|
|
376
|
-
replacement = replacement.replace("<slot/>",
|
|
377
|
-
replacement = replacement.replace("<slot />",
|
|
374
|
+
let slot_wrapped = if slot_content.trim().is_empty() { String::new() } else { format!("<slot>{}</slot>", slot_content) };
|
|
375
|
+
replacement = replacement.replace("<slot></slot>", &slot_wrapped);
|
|
376
|
+
replacement = replacement.replace("<slot/>", &slot_wrapped);
|
|
377
|
+
replacement = replacement.replace("<slot />", &slot_wrapped);
|
|
378
378
|
|
|
379
379
|
let wrapper = format!("<component>{}</component>", replacement);
|
|
380
380
|
result.replace_range(*start..*end, &wrapper);
|
|
@@ -447,10 +447,10 @@ impl HtmlParser {
|
|
|
447
447
|
replacement = replacement.replace(&prop_binding, prop_value);
|
|
448
448
|
}
|
|
449
449
|
|
|
450
|
-
let
|
|
451
|
-
replacement = replacement.replace("<slot></slot>",
|
|
452
|
-
replacement = replacement.replace("<slot/>",
|
|
453
|
-
replacement = replacement.replace("<slot />",
|
|
450
|
+
let slot_wrapped = if slot_content.trim().is_empty() { String::new() } else { format!("<slot>{}</slot>", slot_content) };
|
|
451
|
+
replacement = replacement.replace("<slot></slot>", &slot_wrapped);
|
|
452
|
+
replacement = replacement.replace("<slot/>", &slot_wrapped);
|
|
453
|
+
replacement = replacement.replace("<slot />", &slot_wrapped);
|
|
454
454
|
|
|
455
455
|
let wrapper = format!("<component>{}</component>", replacement);
|
|
456
456
|
result.replace_range(start..end, &wrapper);
|
package/index.js
CHANGED
|
@@ -5,9 +5,16 @@ import { boot, isBooted, ensureBoot } from './boot.js';
|
|
|
5
5
|
|
|
6
6
|
// Shared instance for queueing listeners before boot
|
|
7
7
|
let vibeInstance = null;
|
|
8
|
+
let resolveInstanceReady = null;
|
|
8
9
|
|
|
9
10
|
const createVibeInstance = () => ({
|
|
10
11
|
_pendingListeners: { afterUpdate: [], afterDomMutation: [], ready: [] },
|
|
12
|
+
// Promise that resolves when the real $.ready resolves post-boot. Lets
|
|
13
|
+
// consumers holding the pre-boot placeholder (e.g. tests awaiting
|
|
14
|
+
// `window.$.ready` before boot has replaced $ with the reactive proxy)
|
|
15
|
+
// wait for readiness without polling. Non-enumerable so it stays out of
|
|
16
|
+
// state snapshots.
|
|
17
|
+
ready: new Promise((resolve) => { resolveInstanceReady = resolve; }),
|
|
11
18
|
on(event, callback) {
|
|
12
19
|
// If booted, delegate to window.$
|
|
13
20
|
if (isBooted() && window.$) {
|
|
@@ -66,4 +73,12 @@ const vibe = (state = {}, config, targetSelector) => {
|
|
|
66
73
|
// Export function to get pending listeners (used by boot.js)
|
|
67
74
|
export const getPendingListeners = () => vibeInstance?._pendingListeners || null;
|
|
68
75
|
|
|
76
|
+
// Forward post-boot $.ready to the pre-boot placeholder's ready promise.
|
|
77
|
+
// Called by boot.js after main() creates the real reactive proxy.
|
|
78
|
+
export const chainInstanceReady = (realReadyPromise) => {
|
|
79
|
+
if (resolveInstanceReady && realReadyPromise) {
|
|
80
|
+
realReadyPromise.then(() => resolveInstanceReady());
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
69
84
|
export default vibe;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ape-egg/vibe",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime-first reactivity with optional compiler",
|
|
6
6
|
"main": "index.js",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"scripts": {
|
|
28
28
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
29
29
|
},
|
|
30
|
-
"author": "
|
|
30
|
+
"author": "kkortes",
|
|
31
31
|
"license": "ISC",
|
|
32
32
|
"publishConfig": {
|
|
33
33
|
"access": "public"
|
|
@@ -28,6 +28,15 @@
|
|
|
28
28
|
|
|
29
29
|
import { BINDING_REGEX } from './constants.js';
|
|
30
30
|
|
|
31
|
+
// innerHTML serialization encodes <, >, &, ", ' inside attribute values.
|
|
32
|
+
// Decode them back before wrapping @[expr] in ${...} for the template literal.
|
|
33
|
+
const decodeEntities = (s) => s
|
|
34
|
+
.replace(/</g, '<')
|
|
35
|
+
.replace(/>/g, '>')
|
|
36
|
+
.replace(/"/g, '"')
|
|
37
|
+
.replace(/'/g, "'")
|
|
38
|
+
.replace(/&/g, '&');
|
|
39
|
+
|
|
31
40
|
// Reusable template element for HTML parsing
|
|
32
41
|
const parseTemplate = document.createElement('template');
|
|
33
42
|
|
|
@@ -59,20 +68,20 @@ const hasNestedStructures = (tree) => {
|
|
|
59
68
|
export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys) => {
|
|
60
69
|
const templateHtml = template.element.innerHTML.trim();
|
|
61
70
|
const escaped = templateHtml.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
|
62
|
-
const code = escaped.replace(BINDING_REGEX, (_, expr) => '${' + expr + '}');
|
|
71
|
+
const code = escaped.replace(BINDING_REGEX, (_, expr) => '${' + decodeEntities(expr) + '}');
|
|
63
72
|
|
|
64
73
|
return new Function(
|
|
65
74
|
'arr',
|
|
66
75
|
...stateKeys,
|
|
67
76
|
`
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
let html = '';
|
|
78
|
+
const len = arr.length;
|
|
79
|
+
for (let ${indexAlias} = 0; ${indexAlias} < len; ${indexAlias}++) {
|
|
80
|
+
const ${itemAlias} = arr[${indexAlias}];
|
|
81
|
+
html += \`${code}\`;
|
|
82
|
+
}
|
|
83
|
+
return html;
|
|
84
|
+
`,
|
|
76
85
|
);
|
|
77
86
|
};
|
|
78
87
|
|
package/runtime/affected.js
CHANGED
|
@@ -6,20 +6,33 @@ import { evalInScope, resolveThisPath } from './utils.js';
|
|
|
6
6
|
// Evaluate conditional expression
|
|
7
7
|
const evaluateCondition = (expression, state, element = null) => !!evalInScope(expression, state, element);
|
|
8
8
|
|
|
9
|
-
// Helper function to check if a match references a specific key
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
// Helper function to check if a match references a specific key.
|
|
10
|
+
// Fast path: exact match or property access (`user.name` matches `user`).
|
|
11
|
+
// Slow path: word-boundary search for complex expressions like `Math.floor(coins / 100)`
|
|
12
|
+
// where the key appears as an identifier anywhere in the expression.
|
|
13
|
+
const isIdentChar = (c) => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c === '_' || c === '$';
|
|
14
|
+
|
|
15
|
+
const matchesKey = (matchStr, key) => {
|
|
16
|
+
if (matchStr === key || matchStr.startsWith(key + '.') || matchStr.startsWith(key + '[')) return true;
|
|
17
|
+
|
|
18
|
+
// Search for key as a standalone identifier (word boundaries on both sides)
|
|
19
|
+
let i = 0;
|
|
20
|
+
while ((i = matchStr.indexOf(key, i)) !== -1) {
|
|
21
|
+
const before = i === 0 ? '' : matchStr[i - 1];
|
|
22
|
+
const after = i + key.length >= matchStr.length ? '' : matchStr[i + key.length];
|
|
23
|
+
if (!isIdentChar(before) && !isIdentChar(after)) return true;
|
|
24
|
+
i += key.length;
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
};
|
|
14
28
|
|
|
15
29
|
const recursive = (tree, state, newState, affected, scopedStateForHydration = null, depth = 0) => {
|
|
16
30
|
// Handle iteration nodes specially
|
|
17
31
|
if (tree.type === 'iteration') {
|
|
18
|
-
|
|
19
|
-
const arrayPath = resolveThisPath(tree.meta.arrayPath, tree.meta.startComment?.parentElement);
|
|
32
|
+
const resolvedExpr = resolveThisPath(tree.meta.arrayPath, tree.meta.startComment?.parentElement);
|
|
20
33
|
|
|
21
|
-
const oldArray = resolvePath(state,
|
|
22
|
-
const newArray = resolvePath(newState,
|
|
34
|
+
const oldArray = evalInScope(resolvedExpr, state, tree.meta.startComment?.parentElement) ?? resolvePath(state, resolvedExpr);
|
|
35
|
+
const newArray = evalInScope(resolvedExpr, newState, tree.meta.startComment?.parentElement) ?? resolvePath(newState, resolvedExpr);
|
|
23
36
|
|
|
24
37
|
// Fast path: reference comparison (arrays are typically replaced, not mutated)
|
|
25
38
|
// This avoids expensive O(n) deepEqual for large arrays
|
|
@@ -55,11 +68,15 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
55
68
|
|
|
56
69
|
for (const instance of tree.runtime.instances) {
|
|
57
70
|
if (instance.tree && instance.scopedState) {
|
|
58
|
-
//
|
|
59
|
-
//
|
|
71
|
+
// Build plain-object snapshots for comparison. scopedState is a live Proxy
|
|
72
|
+
// that reflects current global state — spreading it gives us the local vars
|
|
73
|
+
// (cat, index) plus current global values. We then override globals with the
|
|
74
|
+
// actual old (state) / new (newState) values so binding comparisons can detect
|
|
75
|
+
// which state keys changed.
|
|
76
|
+
const mergedOldState = { ...instance.scopedState, ...state };
|
|
60
77
|
const mergedNewState = { ...instance.scopedState, ...newState };
|
|
61
|
-
// Pass
|
|
62
|
-
recursive(instance.tree,
|
|
78
|
+
// Pass scopedState as scopedStateForHydration so evalInScope can access iteration variables
|
|
79
|
+
recursive(instance.tree, mergedOldState, mergedNewState, affected, instance.scopedState, depth + 1);
|
|
63
80
|
}
|
|
64
81
|
}
|
|
65
82
|
}
|
|
@@ -220,17 +237,20 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
220
237
|
for (const m of nameMatches) {
|
|
221
238
|
// Resolve this.property to componentId.property
|
|
222
239
|
const resolvedInner = resolveThisPath(m.inner, tree.element);
|
|
240
|
+
// HTML lowercases attribute names, so expression may be lowercase while state
|
|
241
|
+
// keys are camelCase. Match case-insensitively by trying both direct and lowercased.
|
|
242
|
+
const matchKey = (key) => matchesKey(resolvedInner, key) || matchesKey(resolvedInner, key.toLowerCase());
|
|
223
243
|
|
|
224
|
-
const noMatch = !shallowState.some(
|
|
244
|
+
const noMatch = !shallowState.some(matchKey);
|
|
225
245
|
|
|
226
246
|
let shouldAffect = false;
|
|
227
247
|
|
|
228
248
|
if (isInitialHydration) {
|
|
229
|
-
const newMatches = shallowNewState.filter(
|
|
249
|
+
const newMatches = shallowNewState.filter(matchKey);
|
|
230
250
|
shouldAffect = noMatch || newMatches.length > 0;
|
|
231
251
|
} else {
|
|
232
252
|
const changedKeys = shallowNewState.filter((key) =>
|
|
233
|
-
|
|
253
|
+
matchKey(key) && state[key] !== newState[key]
|
|
234
254
|
);
|
|
235
255
|
shouldAffect = noMatch || changedKeys.length > 0;
|
|
236
256
|
}
|