@ape-egg/vibe 2.0.0 → 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.
- package/CHANGELOG.md +78 -0
- package/README.md +1 -1
- package/ROADMAP.md +45 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +1 -1
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +100 -13
- package/compiler/src/compiler/iteration_optimizer.rs +188 -112
- package/compiler/src/compiler/manifest_builder.rs +5 -9
- package/compiler/src/parser/html.rs +194 -31
- package/package.json +1 -1
- package/runtime/cleanup.js +12 -3
- package/runtime/component.js +130 -43
- package/runtime/conditionals.js +16 -9
- package/runtime/hydrate.js +12 -3
- package/runtime/index.js +23 -2
- package/runtime/iterate.js +103 -22
- package/runtime/loop-scope.js +49 -3
- package/runtime/parse.js +8 -0
- package/runtime/pre-compiled-manifest.js +56 -155
- package/runtime/utils.js +12 -2
package/runtime/index.js
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
PHASE_READY,
|
|
24
24
|
DEHYDRATE_CLASS_OR_ATTR,
|
|
25
25
|
} from './constants.js';
|
|
26
|
-
import { processComponent, abortComponentFetch, collectComponentIds, releaseOrphanedComponentState, renderComponentTemplate } from './component.js';
|
|
26
|
+
import { processComponent, abortComponentFetch, collectComponentIds, releaseOrphanedComponentState, renderComponentTemplate, executeCompiledComponentScripts } from './component.js';
|
|
27
27
|
import { debugLog } from './debug.js';
|
|
28
28
|
import { shouldCleanup, cleanup } from './cleanup.js';
|
|
29
29
|
import { reconcile } from './reconcile.js';
|
|
@@ -706,6 +706,18 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
706
706
|
// first conditional/binding eval.
|
|
707
707
|
if (typeof window !== 'undefined') window.$ = $;
|
|
708
708
|
|
|
709
|
+
// Compiled pages: execute build-inlined component scripts (neutered to
|
|
710
|
+
// type="vibe-module" by the compiler) through the runtime's component-script
|
|
711
|
+
// pipeline — same injected component(), same scoped `$`, same import
|
|
712
|
+
// rewriting as fetched scripts. Runs after `window.$` is live so
|
|
713
|
+
// `const id = component(state); $[id].x = ...` captures the reactive proxy,
|
|
714
|
+
// and before initial hydration so synchronous scripts' state is already
|
|
715
|
+
// registered when `this.` bindings first evaluate. Async scripts gate
|
|
716
|
+
// `ready` via compiledScriptsDone below.
|
|
717
|
+
let compiledScriptsDone = true;
|
|
718
|
+
const compiledScriptsPending = executeCompiledComponentScripts();
|
|
719
|
+
if (compiledScriptsPending) compiledScriptsDone = false;
|
|
720
|
+
|
|
709
721
|
// Initial hydration - pass plain values so iteration can do reference comparison
|
|
710
722
|
const initialState = extractPlainValue($);
|
|
711
723
|
const affectedElements = affected(parsedTree, initialState, initialState);
|
|
@@ -1125,7 +1137,7 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
1125
1137
|
|
|
1126
1138
|
// Check if cleanup should run
|
|
1127
1139
|
const checkCleanup = () => {
|
|
1128
|
-
if (cleanupExecuted || isCompiling) return;
|
|
1140
|
+
if (cleanupExecuted || isCompiling || !compiledScriptsDone) return;
|
|
1129
1141
|
|
|
1130
1142
|
// Check for pending mutations first
|
|
1131
1143
|
const pendingMutations = observer ? observer.takeRecords() : [];
|
|
@@ -1157,6 +1169,15 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
1157
1169
|
// Register hook to check for cleanup readiness after each mutation batch
|
|
1158
1170
|
hooks.afterDomMutation.push(checkCleanup);
|
|
1159
1171
|
|
|
1172
|
+
// Async compiled component scripts (imports) finish after boot — unlock the
|
|
1173
|
+
// ready gate and re-check once their state has merged into `$`.
|
|
1174
|
+
if (compiledScriptsPending) {
|
|
1175
|
+
compiledScriptsPending.then(() => {
|
|
1176
|
+
compiledScriptsDone = true;
|
|
1177
|
+
checkCleanup();
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1160
1181
|
// Process component elements after initialization - MutationObserver will handle hydration
|
|
1161
1182
|
componentProcessingStarted = true;
|
|
1162
1183
|
|
package/runtime/iterate.js
CHANGED
|
@@ -87,6 +87,40 @@ const BATCH_ATTR_BINDING_REGEX = new RegExp(
|
|
|
87
87
|
'g',
|
|
88
88
|
);
|
|
89
89
|
|
|
90
|
+
// Apply fn to tag spans only (`<el ...>`, quote-aware so a `>` inside an
|
|
91
|
+
// attribute value doesn't end the span), leaving text spans and comments
|
|
92
|
+
// untouched. Used to scope the name-binding rewrite to positions where a
|
|
93
|
+
// name binding can actually occur.
|
|
94
|
+
const mapTagSpans = (html, fn) => {
|
|
95
|
+
let out = '';
|
|
96
|
+
let i = 0;
|
|
97
|
+
while (i < html.length) {
|
|
98
|
+
const lt = html.indexOf('<', i);
|
|
99
|
+
if (lt === -1) {
|
|
100
|
+
out += html.slice(i);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
out += html.slice(i, lt);
|
|
104
|
+
let j = lt + 1;
|
|
105
|
+
let quote = null;
|
|
106
|
+
while (j < html.length) {
|
|
107
|
+
const c = html[j];
|
|
108
|
+
if (quote) {
|
|
109
|
+
if (c === quote) quote = null;
|
|
110
|
+
} else if (c === '"' || c === "'") {
|
|
111
|
+
quote = c;
|
|
112
|
+
} else if (c === '>') {
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
j++;
|
|
116
|
+
}
|
|
117
|
+
const span = html.slice(lt, Math.min(j + 1, html.length));
|
|
118
|
+
out += /^<\/?[a-zA-Z]/.test(span) ? fn(span) : span;
|
|
119
|
+
i = j + 1;
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
};
|
|
123
|
+
|
|
90
124
|
// Whether a hydrate'd attribute is a "value-style" string attr (kept verbatim)
|
|
91
125
|
// rather than a boolean-coerced attr (added/removed by truthiness). Mirrors
|
|
92
126
|
// the predicate hydrate.js uses, so batch and clone classify identically.
|
|
@@ -126,6 +160,17 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
|
|
|
126
160
|
const componentId = anchorEl ? findComponentIdForElement(anchorEl) : null;
|
|
127
161
|
const domPropertyWrites = [];
|
|
128
162
|
|
|
163
|
+
// Trim binding-bearing text nodes — the clone path writes the interpolated
|
|
164
|
+
// text node content trimmed (hydrate.js), so the batch template must not
|
|
165
|
+
// carry the author's indentation around a binding that sits on its own line.
|
|
166
|
+
const textWalker = document.createTreeWalker(tplClone, NodeFilter.SHOW_TEXT);
|
|
167
|
+
while (textWalker.nextNode()) {
|
|
168
|
+
const textNode = textWalker.currentNode;
|
|
169
|
+
if (textNode.textContent.includes('@[')) {
|
|
170
|
+
textNode.textContent = textNode.textContent.trim();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
129
174
|
const allEls = tplClone.querySelectorAll('*');
|
|
130
175
|
for (let n = 0; n < allEls.length; n++) {
|
|
131
176
|
const el = allEls[n];
|
|
@@ -167,7 +212,10 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
|
|
|
167
212
|
|
|
168
213
|
// Name bindings: `<el @[expr]>` — emit ` resolvedName=""` when truthy, else
|
|
169
214
|
// emit nothing. The lookahead `(?=[\s/>])` distinguishes name-position
|
|
170
|
-
// bindings from attribute-value-position bindings (which are followed by `=`)
|
|
215
|
+
// bindings from attribute-value-position bindings (which are followed by `=`)
|
|
216
|
+
// — but a TEXT-position binding on its own line is also whitespace-bounded,
|
|
217
|
+
// so the pass runs only over tag spans (mapTagSpans): name bindings can only
|
|
218
|
+
// exist inside a tag.
|
|
171
219
|
//
|
|
172
220
|
// HTML parses attribute names lowercase, so a binding like
|
|
173
221
|
// `<icon @[attrName]>` arrives here as `@[attrname]` and a dotted form like
|
|
@@ -182,7 +230,7 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
|
|
|
182
230
|
// Bracket / call expressions pass through unchanged — they need a real
|
|
183
231
|
// evaluator and aren't worth special-casing here.
|
|
184
232
|
let needsCiWalker = false;
|
|
185
|
-
code = code.replace(BATCH_NAME_BINDING_REGEX, (_, _ws, expr) => {
|
|
233
|
+
code = mapTagSpans(code, (tag) => tag.replace(BATCH_NAME_BINDING_REGEX, (_, _ws, expr) => {
|
|
186
234
|
let decExpr = decodeEntities(expr);
|
|
187
235
|
if (decExpr.includes('[') || decExpr.includes('(')) {
|
|
188
236
|
return '${(' + decExpr + ') ? \' \' + (' + decExpr + ') + \'=""\' : \'\'}';
|
|
@@ -203,7 +251,7 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
|
|
|
203
251
|
'${(()=>{const _v=_walkCi(' + ciHead + ',' + tailJSON +
|
|
204
252
|
');return _v?\' \'+_v+\'=""\':\'\';})()}'
|
|
205
253
|
);
|
|
206
|
-
});
|
|
254
|
+
}));
|
|
207
255
|
|
|
208
256
|
// Pure-binding attributes (`attr="@[expr]"`) — classify by attribute name:
|
|
209
257
|
// DOM property → emit attribute (post-stamp also writes the property)
|
|
@@ -266,23 +314,31 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
|
|
|
266
314
|
// reachable from binding expressions because `window` is in evalInScope's
|
|
267
315
|
// known-globals list. Each entry is freed when the owning component element
|
|
268
316
|
// is detached (see releaseOrphanedIterationProps).
|
|
317
|
+
//
|
|
318
|
+
// The name is intentionally all-lowercase. resolveIterationComponentProps
|
|
319
|
+
// injects `@[window.__vibeiterprops._pN]` into the component's bindings, and
|
|
320
|
+
// prop substitution carries that accessor into the template's own bindings —
|
|
321
|
+
// including name-bindings (`<icon @[props.element]>`). HTML lowercases
|
|
322
|
+
// attribute names, so a camelCase accessor would arrive at hydrate as
|
|
323
|
+
// `window.__vibeiterprops` and resolve to undefined, silently dropping the
|
|
324
|
+
// attribute. Keeping the global lowercase makes it survive that normalization.
|
|
269
325
|
let __vibeIterPropCounter = 0;
|
|
270
326
|
const ensureIterPropsRegistry = () => {
|
|
271
|
-
if (!window.
|
|
272
|
-
return window.
|
|
327
|
+
if (!window.__vibeiterprops) window.__vibeiterprops = {};
|
|
328
|
+
return window.__vibeiterprops;
|
|
273
329
|
};
|
|
274
330
|
|
|
275
331
|
// Walk a removed subtree and free any iteration-prop registry slots stashed
|
|
276
332
|
// on `<component>` elements inside it. Called from the mutation-observer
|
|
277
333
|
// cleanup path after DOM detachment.
|
|
278
334
|
export const releaseOrphanedIterationProps = (nodes) => {
|
|
279
|
-
if (!window.
|
|
335
|
+
if (!window.__vibeiterprops) return;
|
|
280
336
|
for (const node of nodes) {
|
|
281
337
|
if (node.nodeType !== 1) continue;
|
|
282
338
|
const free = (el) => {
|
|
283
339
|
const ids = el._vibeIterPropIds;
|
|
284
340
|
if (!ids) return;
|
|
285
|
-
for (const id of ids) delete window.
|
|
341
|
+
for (const id of ids) delete window.__vibeiterprops[id];
|
|
286
342
|
el._vibeIterPropIds = null;
|
|
287
343
|
};
|
|
288
344
|
free(node);
|
|
@@ -293,7 +349,7 @@ export const releaseOrphanedIterationProps = (nodes) => {
|
|
|
293
349
|
// For <component src> elements inside an iteration instance, evaluate any
|
|
294
350
|
// `@[expr]` attribute bindings against the iteration's scoped state and route
|
|
295
351
|
// every resolved value through the global iteration-prop registry. The prop
|
|
296
|
-
// attribute becomes `@[window.
|
|
352
|
+
// attribute becomes `@[window.__vibeiterprops._pN]` — a live binding into the
|
|
297
353
|
// registry slot — for both primitives and objects. The original expression is
|
|
298
354
|
// stashed on the element so the iteration's update path can re-evaluate it
|
|
299
355
|
// against the new scope and refresh the slot, propagating the change into the
|
|
@@ -327,7 +383,7 @@ export const resolveIterationComponentProps = (nodes, scopedState) => {
|
|
|
327
383
|
const registry = ensureIterPropsRegistry();
|
|
328
384
|
const id = `_p${__vibeIterPropCounter++}`;
|
|
329
385
|
registry[id] = value;
|
|
330
|
-
el.setAttribute(attr.name, `@[window.
|
|
386
|
+
el.setAttribute(attr.name, `@[window.__vibeiterprops.${id}]`);
|
|
331
387
|
el.setAttribute('data-vibe-iter-prop', '');
|
|
332
388
|
(el._vibeIterPropIds = el._vibeIterPropIds || []).push(id);
|
|
333
389
|
(el._vibeIterPropExprs = el._vibeIterPropExprs || []).push({ id, attrName: attr.name, expr });
|
|
@@ -412,7 +468,7 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
|
|
|
412
468
|
|
|
413
469
|
// Walk an inlined component's parsed tree and force `updateIteration` on any
|
|
414
470
|
// iteration node whose arrayPath resolves through the iteration-prop registry
|
|
415
|
-
// (`window.
|
|
471
|
+
// (`window.__vibeiterprops._pN`). The registry slot was just refreshed in
|
|
416
472
|
// place by `refreshIterationComponentProps`, so `affected()` can't notice
|
|
417
473
|
// the change — both old/new evaluations of the path read the same updated
|
|
418
474
|
// value. `updateIteration` is the only place equipped to diff against
|
|
@@ -421,7 +477,7 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
|
|
|
421
477
|
// in sync. Without this, an `<inner-component>` whose template iterates over
|
|
422
478
|
// an array prop stays frozen on its initial-render items when the prop's
|
|
423
479
|
// contents change.
|
|
424
|
-
const REGISTRY_SLOT_REGEX = /
|
|
480
|
+
const REGISTRY_SLOT_REGEX = /__vibeiterprops\.(_p\d+)/;
|
|
425
481
|
const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope, changedSlots) => {
|
|
426
482
|
if (!tree) return;
|
|
427
483
|
if (tree.type === 'iteration') {
|
|
@@ -483,11 +539,19 @@ const applyDomPropertyWrites = (instances, array, state, itemAlias, indexAlias,
|
|
|
483
539
|
const { prop, expr } = domPropertyWrites[indexes[k] | 0];
|
|
484
540
|
const value = evalInScope(expr, localState, el);
|
|
485
541
|
if (el[prop] !== value) el[prop] = value;
|
|
486
|
-
if (
|
|
487
|
-
if (
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
542
|
+
if (prop === 'value') {
|
|
543
|
+
if (value === undefined || value === null) {
|
|
544
|
+
if (el.hasAttribute(prop)) el.removeAttribute(prop);
|
|
545
|
+
} else {
|
|
546
|
+
const str = String(value);
|
|
547
|
+
if (el.getAttribute(prop) !== str) el.setAttribute(prop, str);
|
|
548
|
+
}
|
|
549
|
+
} else if (value) {
|
|
550
|
+
// checked/selected are boolean — presence/absence is the truthful
|
|
551
|
+
// attribute form, matching the clone path in hydrate.js.
|
|
552
|
+
if (el.getAttribute(prop) !== '') el.setAttribute(prop, '');
|
|
553
|
+
} else if (el.hasAttribute(prop)) {
|
|
554
|
+
el.removeAttribute(prop);
|
|
491
555
|
}
|
|
492
556
|
}
|
|
493
557
|
el.removeAttribute('data-vibe-batch');
|
|
@@ -983,7 +1047,7 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
983
1047
|
// mutation was a registry slot rewrite. The previously rendered
|
|
984
1048
|
// items are the only honest record of what was there before.
|
|
985
1049
|
// 2. stateOldArray === newArray — the iteration's arrayPath resolves
|
|
986
|
-
// directly to a registry slot (`window.
|
|
1050
|
+
// directly to a registry slot (`window.__vibeiterprops._pN`); the
|
|
987
1051
|
// slot was swapped in place, so both reads return the same NEW
|
|
988
1052
|
// array.
|
|
989
1053
|
// 3. length mismatch — oldState predates the current render.
|
|
@@ -1039,14 +1103,31 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
1039
1103
|
// actually depend on what changed. Before tearing down and recreating every
|
|
1040
1104
|
// row, re-run the batch: if it yields identical HTML, the rows don't depend
|
|
1041
1105
|
// on what changed, so keep the existing DOM nodes — preserving their event
|
|
1042
|
-
// listeners (e.g. tooltip mouseleave) and any in-progress
|
|
1043
|
-
//
|
|
1044
|
-
//
|
|
1106
|
+
// listeners (e.g. tooltip mouseleave) and any in-progress click on a row
|
|
1107
|
+
// control. DOM-property writes (value/checked/etc.) aren't reflected in the
|
|
1108
|
+
// HTML string, so they're re-applied against the kept rows — property
|
|
1109
|
+
// assignment also wins over a user-dirtied checkbox, which an attribute
|
|
1110
|
+
// rewrite wouldn't.
|
|
1045
1111
|
const rt = iterationNode.runtime;
|
|
1046
|
-
if (rt.batchFn && rt.lastBatchHtml !== undefined
|
|
1112
|
+
if (rt.batchFn && rt.lastBatchHtml !== undefined) {
|
|
1047
1113
|
const stateValues = rt.stateKeys.map((k) => newState[k]);
|
|
1048
1114
|
const newHtml = rt.batchFn(newArray, ...stateValues, newState);
|
|
1049
|
-
if (newHtml === rt.lastBatchHtml)
|
|
1115
|
+
if (newHtml === rt.lastBatchHtml) {
|
|
1116
|
+
if (rt.domPropertyWrites?.length) {
|
|
1117
|
+
// Identical HTML implies identical row count — refresh item refs so
|
|
1118
|
+
// $scope handlers and property writes read the live array.
|
|
1119
|
+
for (let i = 0; i < instances.length; i++) instances[i].item = newArray[i];
|
|
1120
|
+
applyDomPropertyWrites(
|
|
1121
|
+
instances,
|
|
1122
|
+
newArray,
|
|
1123
|
+
newState,
|
|
1124
|
+
iterationNode.meta.itemAlias,
|
|
1125
|
+
iterationNode.meta.indexAlias,
|
|
1126
|
+
rt.domPropertyWrites,
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1050
1131
|
}
|
|
1051
1132
|
bulkReplace(iterationNode, newArray, newState, manifest, parentScope);
|
|
1052
1133
|
return;
|
package/runtime/loop-scope.js
CHANGED
|
@@ -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
|
-
//
|
|
55
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
378
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
|
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
|
}
|