@ape-egg/vibe 1.7.2 → 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 +139 -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/Cargo.lock +85 -496
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +3 -11
- 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 +54 -16
- package/runtime/component.js +287 -110
- package/runtime/conditionals.js +99 -7
- package/runtime/constants.js +10 -6
- package/runtime/index.js +142 -47
- package/runtime/iterate.js +364 -142
- package/runtime/iteration-utils.js +5 -1
- package/runtime/manifest.js +2 -1
- package/runtime/parse.js +67 -80
- package/runtime/pre-compiled-iterations.js +34 -21
- package/runtime/pre-compiled-manifest.js +38 -13
- package/runtime/reconcile.js +621 -0
- package/runtime/state.js +26 -7
- package/runtime/utils.js +97 -5
- package/vibe.css +3 -1
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
import { ITERATION_START_REGEX, CONDITIONAL_START_REGEX } from './constants.js';
|
|
2
|
+
|
|
3
|
+
// Reconcile a live, Vibe-managed subtree against new source HTML, applying
|
|
4
|
+
// the minimal mutation needed to align them. Vibe-owned regions (iterations,
|
|
5
|
+
// conditionals, components) are treated as opaque — their internals are
|
|
6
|
+
// state-driven and owned by iterate.js / conditionals.js / component.js.
|
|
7
|
+
//
|
|
8
|
+
// Walking strategy: tag-aligned, two-pointer. For each pair we either
|
|
9
|
+
// - match (recurse into element children, or update text/attrs in place),
|
|
10
|
+
// - skip (Vibe-owned region; jump both pointers past it),
|
|
11
|
+
// - insert (source has a node live doesn't), or
|
|
12
|
+
// - remove (live has a node source doesn't).
|
|
13
|
+
// DOM identity survives wherever a match is found.
|
|
14
|
+
|
|
15
|
+
const COMMENT = 8;
|
|
16
|
+
const ELEMENT = 1;
|
|
17
|
+
const TEXT = 3;
|
|
18
|
+
|
|
19
|
+
const isIterationStart = (n) =>
|
|
20
|
+
n.nodeType === COMMENT && ITERATION_START_REGEX.test(n.textContent.trim());
|
|
21
|
+
const isConditionalStart = (n) =>
|
|
22
|
+
n.nodeType === COMMENT && CONDITIONAL_START_REGEX.test(n.textContent.trim());
|
|
23
|
+
const isRegionEnd = (n) => {
|
|
24
|
+
if (n.nodeType !== COMMENT) return false;
|
|
25
|
+
const t = n.textContent.trim();
|
|
26
|
+
return t === '/each' || t === '/if';
|
|
27
|
+
};
|
|
28
|
+
const isComponentWrapper = (n) =>
|
|
29
|
+
n.nodeType === ELEMENT &&
|
|
30
|
+
(n.tagName === 'COMPONENT' || (n.tagName === 'DIV' && n.classList?.contains('component')));
|
|
31
|
+
|
|
32
|
+
// <slot> element pairs are opaque: the slot's children are owned by the
|
|
33
|
+
// CALLER of the component, not the template being reconciled. So if source
|
|
34
|
+
// is a component template (empty <slot></slot>) and live is the processed
|
|
35
|
+
// wrapper (populated <slot>), recursing in would destroy the slot content.
|
|
36
|
+
// Slot content is updated by the page-update path's component-pair handler,
|
|
37
|
+
// which recurses INTO the slot (bypassing the slot element itself).
|
|
38
|
+
const isSlotElement = (n) =>
|
|
39
|
+
n.nodeType === ELEMENT &&
|
|
40
|
+
(n.tagName === 'SLOT' || (n.tagName === 'DIV' && n.classList?.contains('slot')));
|
|
41
|
+
|
|
42
|
+
// Two nodes "look matchable" — same nodeType, same tag for elements. Used
|
|
43
|
+
// by the realignment lookahead below.
|
|
44
|
+
const nodesMatch = (a, b) => {
|
|
45
|
+
if (!a || !b) return false;
|
|
46
|
+
if (a.nodeType !== b.nodeType) return false;
|
|
47
|
+
if (a.nodeType === ELEMENT) return a.tagName === b.tagName;
|
|
48
|
+
return true;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// At a mismatch, scan ahead within a small window on both sides to find a
|
|
52
|
+
// near-match. Returns { dl, ds } where dl live nodes need to be removed and
|
|
53
|
+
// ds source nodes need to be inserted before the realignment. Avoids the
|
|
54
|
+
// cascading wholesale-replace failure mode where a single extra node on one
|
|
55
|
+
// side destroys everything that follows.
|
|
56
|
+
const REALIGN_WINDOW = 4;
|
|
57
|
+
const findRealignment = (liveNodes, li, sourceNodes, si) => {
|
|
58
|
+
for (let total = 1; total <= 2 * REALIGN_WINDOW; total++) {
|
|
59
|
+
for (let dl = 0; dl <= Math.min(total, REALIGN_WINDOW); dl++) {
|
|
60
|
+
const ds = total - dl;
|
|
61
|
+
if (ds < 0 || ds > REALIGN_WINDOW) continue;
|
|
62
|
+
if (dl === 0 && ds === 0) continue;
|
|
63
|
+
if (nodesMatch(liveNodes[li + dl], sourceNodes[si + ds])) {
|
|
64
|
+
return { dl, ds };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Trim leading/trailing whitespace-only text nodes. Mirrors vibe's
|
|
72
|
+
// component.js, which calls .trim() on slot content before inlining — so
|
|
73
|
+
// the live <slot> has no edge whitespace, but DOMParser preserves it on
|
|
74
|
+
// the source side. Used when recursing into component slots.
|
|
75
|
+
const trimWsEdges = (nodes) => {
|
|
76
|
+
let start = 0;
|
|
77
|
+
let end = nodes.length;
|
|
78
|
+
while (start < end && nodes[start].nodeType === TEXT && !nodes[start].textContent.trim()) start++;
|
|
79
|
+
while (end > start && nodes[end - 1].nodeType === TEXT && !nodes[end - 1].textContent.trim()) end--;
|
|
80
|
+
return nodes.slice(start, end);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Find the <slot> in `el` that holds the source's slot content. Vibe wraps
|
|
84
|
+
// slot content in a <slot> element after inlining; with nested components
|
|
85
|
+
// (e.g. Layout uses Authorization as a wrapping component, and Layout's own
|
|
86
|
+
// <slot> ends up inside Authorization's slot), there can be many <slot>s in
|
|
87
|
+
// the wrapper. The right one is the slot whose first element child has the
|
|
88
|
+
// same tag as the source's first element child (that's the user's content).
|
|
89
|
+
const firstElementChildOf = (parent) => {
|
|
90
|
+
for (const n of parent.children || []) return n;
|
|
91
|
+
return null;
|
|
92
|
+
};
|
|
93
|
+
const firstElementOfNodes = (nodes) => {
|
|
94
|
+
for (const n of nodes) if (n.nodeType === ELEMENT) return n;
|
|
95
|
+
return null;
|
|
96
|
+
};
|
|
97
|
+
const findOwnSlot = (el, srcChildren) => {
|
|
98
|
+
const srcFirst = firstElementOfNodes(srcChildren);
|
|
99
|
+
if (!srcFirst) return null;
|
|
100
|
+
for (const slot of el.querySelectorAll('slot')) {
|
|
101
|
+
const liveFirst = firstElementChildOf(slot);
|
|
102
|
+
if (liveFirst?.tagName === srcFirst.tagName) return slot;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const hasBinding = (s) => /@\[.+?\]/.test(s);
|
|
108
|
+
|
|
109
|
+
// Per-text-node cache of the last source text we saw (containing @[...]).
|
|
110
|
+
// Used to surgically update STATIC portions of bound text — e.g. editing
|
|
111
|
+
// "Level @[lvl]" to "Level: @[lvl]" — by extracting the binding's resolved
|
|
112
|
+
// value from live using the OLD statics and re-applying with NEW statics.
|
|
113
|
+
//
|
|
114
|
+
// Caveat: the very first reconcile populates the cache without applying
|
|
115
|
+
// anything, so a freshly-loaded page needs ONE wasted save to seed the
|
|
116
|
+
// cache. Subsequent edits propagate.
|
|
117
|
+
const boundSourceCache = new WeakMap();
|
|
118
|
+
|
|
119
|
+
const updateBoundText = (live, newSrc, parent, log) => {
|
|
120
|
+
const oldSrc = boundSourceCache.get(live);
|
|
121
|
+
boundSourceCache.set(live, newSrc);
|
|
122
|
+
if (oldSrc === undefined || oldSrc === newSrc) return;
|
|
123
|
+
|
|
124
|
+
// Bindings must match in count + order to safely propagate static changes.
|
|
125
|
+
// (If the user added or reordered bindings, we can't infer where values go.)
|
|
126
|
+
const oldBindings = oldSrc.match(/@\[[^\]]+\]/g) || [];
|
|
127
|
+
const newBindings = newSrc.match(/@\[[^\]]+\]/g) || [];
|
|
128
|
+
if (oldBindings.length !== newBindings.length) return;
|
|
129
|
+
for (let i = 0; i < oldBindings.length; i++) {
|
|
130
|
+
if (oldBindings[i] !== newBindings[i]) return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Split each source by binding markers → static segments around each binding.
|
|
134
|
+
const oldStatic = oldSrc.split(/@\[[^\]]+\]/);
|
|
135
|
+
const newStatic = newSrc.split(/@\[[^\]]+\]/);
|
|
136
|
+
|
|
137
|
+
// Extract resolved values from live by stripping the old statics.
|
|
138
|
+
const liveText = live.textContent;
|
|
139
|
+
const values = [];
|
|
140
|
+
let pos = 0;
|
|
141
|
+
for (let i = 0; i < oldStatic.length - 1; i++) {
|
|
142
|
+
const before = oldStatic[i];
|
|
143
|
+
const after = oldStatic[i + 1];
|
|
144
|
+
if (liveText.slice(pos, pos + before.length) !== before) return;
|
|
145
|
+
pos += before.length;
|
|
146
|
+
let endPos;
|
|
147
|
+
if (i === oldStatic.length - 2) {
|
|
148
|
+
endPos = liveText.length - after.length;
|
|
149
|
+
if (endPos < pos || liveText.slice(endPos) !== after) return;
|
|
150
|
+
} else {
|
|
151
|
+
endPos = liveText.indexOf(after, pos);
|
|
152
|
+
if (endPos < 0) return;
|
|
153
|
+
}
|
|
154
|
+
values.push(liveText.slice(pos, endPos));
|
|
155
|
+
pos = endPos;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let result = newStatic[0];
|
|
159
|
+
for (let i = 0; i < values.length; i++) {
|
|
160
|
+
result += values[i] + newStatic[i + 1];
|
|
161
|
+
}
|
|
162
|
+
if (result === liveText) return;
|
|
163
|
+
|
|
164
|
+
live.textContent = result;
|
|
165
|
+
log.text++;
|
|
166
|
+
log.changes.push(`bound text in ${describe(parent)}: ${JSON.stringify(result.slice(0, 60))}`);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Find the matching closing comment (`<!-- /each -->` or `<!-- /if -->`) for
|
|
170
|
+
// a region starting at startIdx. Tracks depth so nested regions are handled.
|
|
171
|
+
// `endLimit` bounds the search (defaults to nodes.length).
|
|
172
|
+
const findRegionEnd = (nodes, startIdx, endLimit) => {
|
|
173
|
+
const limit = endLimit ?? nodes.length;
|
|
174
|
+
let depth = 1;
|
|
175
|
+
for (let i = startIdx + 1; i < limit; i++) {
|
|
176
|
+
const n = nodes[i];
|
|
177
|
+
if (isIterationStart(n) || isConditionalStart(n)) depth++;
|
|
178
|
+
else if (isRegionEnd(n)) {
|
|
179
|
+
depth--;
|
|
180
|
+
if (depth === 0) return i;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return limit - 1;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Find the depth-0 `<!-- else -->` marker between an `<!-- if -->` and its
|
|
187
|
+
// `<!-- /if -->`. Returns -1 if there's no else branch.
|
|
188
|
+
const findElseMarker = (nodes, startIdx, endIdx) => {
|
|
189
|
+
let depth = 0;
|
|
190
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
191
|
+
const n = nodes[i];
|
|
192
|
+
if (n.nodeType !== COMMENT) continue;
|
|
193
|
+
const t = n.textContent.trim();
|
|
194
|
+
if (t.startsWith('if ') || t.startsWith('each ')) depth++;
|
|
195
|
+
else if (t === '/if' || t === '/each') depth--;
|
|
196
|
+
else if (depth === 0 && t === 'else') return i;
|
|
197
|
+
}
|
|
198
|
+
return -1;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// Framework-managed markers that reconcile must not strip on source absence.
|
|
202
|
+
// `vibe-fouc` can appear as either an attribute or a class token (per
|
|
203
|
+
// vibe.css and runtime/cleanup.js) — both forms are protected below.
|
|
204
|
+
const PRESERVED_ATTRS = new Set(['vibe-fouc']);
|
|
205
|
+
const PRESERVED_CLASSES = new Set(['vibe-fouc']);
|
|
206
|
+
const isPreservedAttr = (name) => name.startsWith('data-vibe-') || PRESERVED_ATTRS.has(name);
|
|
207
|
+
|
|
208
|
+
// Merge a source `class` value with any preserved tokens already on live, so
|
|
209
|
+
// framework-managed tokens (e.g. .vibe-fouc) survive reconciliation.
|
|
210
|
+
const mergeClass = (srcValue, live) => {
|
|
211
|
+
const tokens = new Set(srcValue.split(/\s+/).filter(Boolean));
|
|
212
|
+
for (const t of live.classList) {
|
|
213
|
+
if (PRESERVED_CLASSES.has(t)) tokens.add(t);
|
|
214
|
+
}
|
|
215
|
+
return [...tokens].join(' ');
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// A description string for an element, for log readability.
|
|
219
|
+
const describe = (el) => {
|
|
220
|
+
if (!el || el.nodeType !== ELEMENT) return String(el?.nodeName || el);
|
|
221
|
+
const id = el.id ? '#' + el.id : '';
|
|
222
|
+
const cls = el.classList?.length ? '.' + [...el.classList].join('.') : '';
|
|
223
|
+
return el.tagName.toLowerCase() + id + cls;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const reconcileAttributes = (live, src, log) => {
|
|
227
|
+
// Detect vibe name-bindings: source attribute NAMES containing @[...]
|
|
228
|
+
// (e.g. <page @[pageName]>). Vibe resolves these to dynamic attribute
|
|
229
|
+
// names at runtime, so the live element has whatever name `@[pageName]`
|
|
230
|
+
// evaluated to (e.g. `brawlers`). We can't add the literal `@[pagename]`
|
|
231
|
+
// attribute, and we can't reliably tell which live attribute maps to the
|
|
232
|
+
// binding — so when any name binding is present, skip the strip phase
|
|
233
|
+
// entirely. Vibe owns this element's attribute set.
|
|
234
|
+
let hasNameBinding = false;
|
|
235
|
+
for (const attr of src.attributes) {
|
|
236
|
+
if (attr.name.includes('@[')) { hasNameBinding = true; continue; }
|
|
237
|
+
// Source-bound attribute? hydrate owns its value — don't overwrite.
|
|
238
|
+
if (hasBinding(attr.value)) continue;
|
|
239
|
+
const next = attr.name === 'class' ? mergeClass(attr.value, live) : attr.value;
|
|
240
|
+
if (live.getAttribute(attr.name) !== next) {
|
|
241
|
+
live.setAttribute(attr.name, next);
|
|
242
|
+
log.attr++;
|
|
243
|
+
log.changes.push(`attr ${describe(live)} [${attr.name}=${JSON.stringify(next)}]`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (hasNameBinding) return;
|
|
247
|
+
for (const attr of [...live.attributes]) {
|
|
248
|
+
if (src.hasAttribute(attr.name)) continue;
|
|
249
|
+
if (isPreservedAttr(attr.name)) continue;
|
|
250
|
+
// data-X → X mirror: if source declares `data-foo`, don't strip live's
|
|
251
|
+
// `foo`. App code commonly mirrors data-* attrs into their canonical
|
|
252
|
+
// form (e.g. <img data-src="..."> with a runtime sync that sets `src`).
|
|
253
|
+
// Stripping `src` here would briefly blank the image until the next sync.
|
|
254
|
+
if (src.hasAttribute('data-' + attr.name)) continue;
|
|
255
|
+
// Source omitted `class` entirely — keep only preserved tokens (or drop).
|
|
256
|
+
if (attr.name === 'class') {
|
|
257
|
+
const kept = [...live.classList].filter((t) => PRESERVED_CLASSES.has(t));
|
|
258
|
+
if (kept.length) live.setAttribute('class', kept.join(' '));
|
|
259
|
+
else live.removeAttribute('class');
|
|
260
|
+
log.attr++;
|
|
261
|
+
log.changes.push(`attr ${describe(live)} [class trimmed]`);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
live.removeAttribute(attr.name);
|
|
265
|
+
log.attr++;
|
|
266
|
+
log.changes.push(`attr ${describe(live)} [-${attr.name}]`);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// Reconcile a slice of liveParent's children (liveNodes[liStart..liEnd))
|
|
271
|
+
// against srcNodes[siStart..sEnd). Mutations target liveParent. liveNodes
|
|
272
|
+
// is a stable snapshot of liveParent.childNodes captured before mutation;
|
|
273
|
+
// indices into it remain valid even after live nodes get removed/replaced.
|
|
274
|
+
//
|
|
275
|
+
// `insideIteration` propagates through recursion. When true, component
|
|
276
|
+
// wrappers are left untouched: iterate.js owns re-rendering via state
|
|
277
|
+
// changes, and pre-resolves `@[item.x]` props to literals before processing
|
|
278
|
+
// — so the live/source prop shapes intentionally differ. Trying to re-mount
|
|
279
|
+
// would insert a fresh <component src> outside iteration scope, losing
|
|
280
|
+
// `item`/`index` and rendering `undefined`.
|
|
281
|
+
const reconcileRange = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart, sEnd, log, insideIteration = false) => {
|
|
282
|
+
let li = liStart;
|
|
283
|
+
let si = siStart;
|
|
284
|
+
|
|
285
|
+
while (li < liEnd || si < sEnd) {
|
|
286
|
+
const live = li < liEnd ? liveNodes[li] : null;
|
|
287
|
+
const src = si < sEnd ? srcNodes[si] : null;
|
|
288
|
+
|
|
289
|
+
if (!live) {
|
|
290
|
+
liveParent.appendChild(srcNodes[si].cloneNode(true));
|
|
291
|
+
log.insert++;
|
|
292
|
+
log.changes.push(`insert ${describe(srcNodes[si])} into ${describe(liveParent)}`);
|
|
293
|
+
si++;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (!src) {
|
|
297
|
+
log.remove++;
|
|
298
|
+
log.changes.push(`remove ${describe(live)} from ${describe(liveParent)}`);
|
|
299
|
+
live.remove();
|
|
300
|
+
li++;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// <script> elements are side-effecting (execute on insertion) and vibe's
|
|
305
|
+
// component pipeline strips them after running. So source typically still
|
|
306
|
+
// has them while live doesn't. Skip orphan scripts on either side rather
|
|
307
|
+
// than insert/remove — preserves correctness without re-executing or
|
|
308
|
+
// accumulating dead scripts on each reconcile.
|
|
309
|
+
const srcIsScript = src?.nodeType === ELEMENT && src.tagName === 'SCRIPT';
|
|
310
|
+
const liveIsScript = live?.nodeType === ELEMENT && live.tagName === 'SCRIPT';
|
|
311
|
+
if (srcIsScript && !liveIsScript) { si++; continue; }
|
|
312
|
+
if (liveIsScript && !srcIsScript) { li++; continue; }
|
|
313
|
+
|
|
314
|
+
const liveIsRegion = isIterationStart(live) || isConditionalStart(live);
|
|
315
|
+
const srcIsRegion = isIterationStart(src) || isConditionalStart(src);
|
|
316
|
+
|
|
317
|
+
// Both pointers at a Vibe-owned region — recurse based on kind.
|
|
318
|
+
if (liveIsRegion && srcIsRegion) {
|
|
319
|
+
const liveEnd = findRegionEnd(liveNodes, li, liEnd);
|
|
320
|
+
const srcEnd = findRegionEnd(srcNodes, si, sEnd);
|
|
321
|
+
if (isConditionalStart(live) && isConditionalStart(src)) {
|
|
322
|
+
reconcileConditionalRegion(liveParent, liveNodes, li, liveEnd, srcNodes, si, srcEnd, log, insideIteration);
|
|
323
|
+
} else if (isIterationStart(live) && isIterationStart(src)) {
|
|
324
|
+
reconcileIterationRegion(liveParent, liveNodes, li, liveEnd, srcNodes, si, srcEnd, log);
|
|
325
|
+
}
|
|
326
|
+
// Mismatched region kinds (if vs each) → opaque, skip both.
|
|
327
|
+
li = liveEnd + 1;
|
|
328
|
+
si = srcEnd + 1;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
// Source removed a region that's still live — drop the live region.
|
|
332
|
+
if (liveIsRegion) {
|
|
333
|
+
const end = findRegionEnd(liveNodes, li, liEnd);
|
|
334
|
+
for (let k = li; k <= end; k++) {
|
|
335
|
+
log.remove++;
|
|
336
|
+
liveNodes[k].remove();
|
|
337
|
+
}
|
|
338
|
+
log.changes.push(`remove vibe region from ${describe(liveParent)}`);
|
|
339
|
+
li = end + 1;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
// Source added a new region — insert it wholesale; MutationObserver
|
|
343
|
+
// will pick up the new comments + children and process them.
|
|
344
|
+
if (srcIsRegion) {
|
|
345
|
+
const end = findRegionEnd(srcNodes, si, sEnd);
|
|
346
|
+
for (let k = si; k <= end; k++) {
|
|
347
|
+
liveParent.insertBefore(srcNodes[k].cloneNode(true), live);
|
|
348
|
+
log.insert++;
|
|
349
|
+
}
|
|
350
|
+
log.changes.push(`insert vibe region into ${describe(liveParent)}`);
|
|
351
|
+
si = end + 1;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Component wrappers: the wrapper itself + its inlined template are
|
|
356
|
+
// opaque (state-driven, owned by component.js + the surrounding hydrate).
|
|
357
|
+
// But the SLOT content is user-authored static HTML — recurse into it so
|
|
358
|
+
// edits to a page's slot content (e.g. inside Layout's <slot>) reconcile
|
|
359
|
+
// surgically just like top-level static content.
|
|
360
|
+
//
|
|
361
|
+
// First, check for PROP changes. If the HMR spy in vite-plugin-vibe has
|
|
362
|
+
// stashed the original authored attrs on live._vibeProps, compare them to
|
|
363
|
+
// the source's attrs. Any diff means the callsite re-authored a prop —
|
|
364
|
+
// re-mount by replacing the wrapper with a fresh <component src> so
|
|
365
|
+
// component.js re-fetches and re-inlines with the new values.
|
|
366
|
+
if (isComponentWrapper(live) && isComponentWrapper(src)) {
|
|
367
|
+
// Inside an iteration instance, iterate.js owns re-mounting: props are
|
|
368
|
+
// pre-resolved against iteration scope before processComponent runs, so
|
|
369
|
+
// source `hp="@[item.x]"` intentionally doesn't match live `hp="50"`.
|
|
370
|
+
// Skip the prop-diff + slot recursion entirely; iteration re-renders
|
|
371
|
+
// the instance when its state changes.
|
|
372
|
+
if (insideIteration) {
|
|
373
|
+
li++;
|
|
374
|
+
si++;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (live._vibeProps && src.hasAttribute('src')) {
|
|
378
|
+
// vibe-fouc is transient and may appear as either an attribute or a
|
|
379
|
+
// class token (see runtime/cleanup.js#FOUC_CLASS_OR_ATTR) — normalize
|
|
380
|
+
// both out so a FOUC flip never reads as a prop change.
|
|
381
|
+
const srcProps = {};
|
|
382
|
+
for (const a of src.attributes) {
|
|
383
|
+
if (a.name === 'vibe-fouc') continue;
|
|
384
|
+
if (a.name === 'class') {
|
|
385
|
+
const kept = a.value.split(/\s+/).filter((t) => t && t !== 'vibe-fouc');
|
|
386
|
+
if (kept.length) srcProps.class = kept.join(' ');
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
srcProps[a.name] = a.value;
|
|
390
|
+
}
|
|
391
|
+
const liveProps = live._vibeProps;
|
|
392
|
+
const liveKeys = Object.keys(liveProps);
|
|
393
|
+
const srcKeys = Object.keys(srcProps);
|
|
394
|
+
const diff =
|
|
395
|
+
liveKeys.length !== srcKeys.length ||
|
|
396
|
+
liveKeys.some((k) => liveProps[k] !== srcProps[k]) ||
|
|
397
|
+
srcKeys.some((k) => !(k in liveProps));
|
|
398
|
+
|
|
399
|
+
if (diff) {
|
|
400
|
+
const fresh = document.createElement(src.tagName);
|
|
401
|
+
if (src.tagName === 'DIV') fresh.className = 'component';
|
|
402
|
+
for (const [name, value] of Object.entries(srcProps)) {
|
|
403
|
+
fresh.setAttribute(name, value);
|
|
404
|
+
}
|
|
405
|
+
fresh.setAttribute('vibe-fouc', '');
|
|
406
|
+
const slotHtml = src.innerHTML.trim();
|
|
407
|
+
fresh._vibeSlotContent = slotHtml;
|
|
408
|
+
// Plugin mirror: _vibeSlotContent is deleted synchronously by
|
|
409
|
+
// vibe's processSingle, so the spy falls back to innerHTML (empty)
|
|
410
|
+
// on the next mount cycle. Without this, the next re-mount would
|
|
411
|
+
// read an empty slot. See vite-plugin-vibe for the symmetric read.
|
|
412
|
+
fresh._vibePluginSlot = slotHtml;
|
|
413
|
+
live.replaceWith(fresh);
|
|
414
|
+
log.replace++;
|
|
415
|
+
log.changes.push(
|
|
416
|
+
`re-mount ${describe(live)} (prop change) live=${JSON.stringify(liveProps)} src=${JSON.stringify(srcProps)}`,
|
|
417
|
+
);
|
|
418
|
+
li++;
|
|
419
|
+
si++;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const srcChildren = trimWsEdges([...src.childNodes]);
|
|
425
|
+
const liveSlot = findOwnSlot(live, srcChildren);
|
|
426
|
+
if (liveSlot) reconcileChildren(liveSlot, srcChildren, log, insideIteration);
|
|
427
|
+
li++;
|
|
428
|
+
si++;
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// <slot> elements are opaque (slot content is caller-owned).
|
|
433
|
+
if (isSlotElement(live) && isSlotElement(src)) {
|
|
434
|
+
li++;
|
|
435
|
+
si++;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Text nodes — update only if source is static. Bound text uses the
|
|
440
|
+
// per-node source cache to surgically re-apply changed STATICS while
|
|
441
|
+
// preserving the binding's resolved value (see updateBoundText above).
|
|
442
|
+
if (live.nodeType === TEXT && src.nodeType === TEXT) {
|
|
443
|
+
if (hasBinding(src.textContent)) {
|
|
444
|
+
updateBoundText(live, src.textContent, liveParent, log);
|
|
445
|
+
} else if (live.textContent !== src.textContent) {
|
|
446
|
+
live.textContent = src.textContent;
|
|
447
|
+
log.text++;
|
|
448
|
+
const preview = src.textContent.trim().slice(0, 60);
|
|
449
|
+
log.changes.push(`text in ${describe(liveParent)}: ${JSON.stringify(preview)}`);
|
|
450
|
+
}
|
|
451
|
+
li++;
|
|
452
|
+
si++;
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Plain comment nodes (non-region) — update text if changed.
|
|
457
|
+
if (live.nodeType === COMMENT && src.nodeType === COMMENT) {
|
|
458
|
+
if (live.textContent !== src.textContent) {
|
|
459
|
+
live.textContent = src.textContent;
|
|
460
|
+
log.text++;
|
|
461
|
+
}
|
|
462
|
+
li++;
|
|
463
|
+
si++;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Element pair, same tag → reconcile in place. Identity preserved.
|
|
468
|
+
if (live.nodeType === ELEMENT && src.nodeType === ELEMENT &&
|
|
469
|
+
live.tagName === src.tagName) {
|
|
470
|
+
reconcileAttributes(live, src, log);
|
|
471
|
+
reconcileChildren(live, [...src.childNodes], log, insideIteration);
|
|
472
|
+
li++;
|
|
473
|
+
si++;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Before falling through to wholesale replace, try a small lookahead
|
|
478
|
+
// realignment — handles cases where one side has an inserted/removed
|
|
479
|
+
// node that would otherwise cascade into a chain of bad replaces.
|
|
480
|
+
const align = findRealignment(liveNodes, li, srcNodes, si);
|
|
481
|
+
if (align && li + align.dl < liEnd && si + align.ds < sEnd) {
|
|
482
|
+
for (let i = 0; i < align.dl; i++) {
|
|
483
|
+
const n = liveNodes[li + i];
|
|
484
|
+
if (n.nodeType === ELEMENT && n.tagName === 'SCRIPT') continue; // see orphan-script note above
|
|
485
|
+
log.remove++;
|
|
486
|
+
log.changes.push(`remove (extra) ${describe(n)} from ${describe(liveParent)}`);
|
|
487
|
+
n.remove();
|
|
488
|
+
}
|
|
489
|
+
const anchor = liveNodes[li + align.dl];
|
|
490
|
+
for (let i = 0; i < align.ds; i++) {
|
|
491
|
+
const n = srcNodes[si + i];
|
|
492
|
+
if (n.nodeType === ELEMENT && n.tagName === 'SCRIPT') continue;
|
|
493
|
+
log.insert++;
|
|
494
|
+
log.changes.push(`insert (new) ${describe(n)} into ${describe(liveParent)}`);
|
|
495
|
+
liveParent.insertBefore(n.cloneNode(true), anchor);
|
|
496
|
+
}
|
|
497
|
+
li += align.dl;
|
|
498
|
+
si += align.ds;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Truly mismatched (no realignment possible) — replace.
|
|
503
|
+
log.replace++;
|
|
504
|
+
log.changes.push(`replace ${describe(live)} → ${describe(src)} in ${describe(liveParent)}`);
|
|
505
|
+
live.replaceWith(src.cloneNode(true));
|
|
506
|
+
li++;
|
|
507
|
+
si++;
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const reconcileChildren = (liveParent, sourceNodes, log, insideIteration = false) => {
|
|
512
|
+
const liveNodes = [...liveParent.childNodes];
|
|
513
|
+
reconcileRange(liveParent, liveNodes, 0, liveNodes.length, sourceNodes, 0, sourceNodes.length, log, insideIteration);
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
// Walk a conditional region: live has one active branch; source has the
|
|
517
|
+
// if-branch and (optionally) the else-branch separated by `<!-- else -->`.
|
|
518
|
+
// Choose the source branch whose first element tag matches live's first
|
|
519
|
+
// element tag, then recurse range-vs-range. If neither matches, leave the
|
|
520
|
+
// region alone (opaque) — vibe will refresh it on next state change.
|
|
521
|
+
const reconcileConditionalRegion = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart, sEnd, log, insideIteration = false) => {
|
|
522
|
+
// Source branches.
|
|
523
|
+
const elseIdx = findElseMarker(srcNodes, siStart + 1, sEnd);
|
|
524
|
+
const ifStart = siStart + 1;
|
|
525
|
+
const ifEnd = elseIdx === -1 ? sEnd : elseIdx;
|
|
526
|
+
const elseStart = elseIdx === -1 ? sEnd : elseIdx + 1;
|
|
527
|
+
const elseEnd = sEnd;
|
|
528
|
+
|
|
529
|
+
// First element on each side decides which branch is live.
|
|
530
|
+
let liveFirstEl = null;
|
|
531
|
+
for (let i = liStart + 1; i < liEnd; i++) {
|
|
532
|
+
if (liveNodes[i].nodeType === ELEMENT) { liveFirstEl = liveNodes[i]; break; }
|
|
533
|
+
}
|
|
534
|
+
if (!liveFirstEl) return; // empty branch; nothing to walk
|
|
535
|
+
|
|
536
|
+
let chosenStart = -1;
|
|
537
|
+
let chosenEnd = -1;
|
|
538
|
+
for (let i = ifStart; i < ifEnd; i++) {
|
|
539
|
+
if (srcNodes[i].nodeType === ELEMENT) {
|
|
540
|
+
if (srcNodes[i].tagName === liveFirstEl.tagName) {
|
|
541
|
+
chosenStart = ifStart; chosenEnd = ifEnd;
|
|
542
|
+
}
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (chosenStart === -1) {
|
|
547
|
+
for (let i = elseStart; i < elseEnd; i++) {
|
|
548
|
+
if (srcNodes[i].nodeType === ELEMENT) {
|
|
549
|
+
if (srcNodes[i].tagName === liveFirstEl.tagName) {
|
|
550
|
+
chosenStart = elseStart; chosenEnd = elseEnd;
|
|
551
|
+
}
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (chosenStart === -1) return; // no matching branch; leave opaque
|
|
557
|
+
|
|
558
|
+
reconcileRange(
|
|
559
|
+
liveParent, liveNodes,
|
|
560
|
+
liStart + 1, liEnd,
|
|
561
|
+
srcNodes, chosenStart, chosenEnd,
|
|
562
|
+
log,
|
|
563
|
+
insideIteration,
|
|
564
|
+
);
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
// Walk an iteration region: live has N rendered instances of the source's
|
|
568
|
+
// template. For each live top-level ELEMENT (rendered instance root), walk
|
|
569
|
+
// it against the corresponding template element (modulo template length).
|
|
570
|
+
// Static text/attr changes propagate to all instances; bound text/attrs
|
|
571
|
+
// are left to vibe (it owns those values via state).
|
|
572
|
+
//
|
|
573
|
+
// Caveat: if the iteration's array state changes after this update, vibe
|
|
574
|
+
// re-renders from its stored template (which we don't update), reverting
|
|
575
|
+
// our changes. The user can save again to re-propagate; perfect propagation
|
|
576
|
+
// would require updating vibe's iteration template metadata directly.
|
|
577
|
+
const reconcileIterationRegion = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart, sEnd, log) => {
|
|
578
|
+
// Per-instance attr/children walk so static text/attr tweaks propagate
|
|
579
|
+
// without a full re-render. Component callsites inside the template are
|
|
580
|
+
// NOT reconciled here — iterate.js caches the template at mount time, and
|
|
581
|
+
// the callsite's live wrapper has iteration-resolved props that never
|
|
582
|
+
// match the authored source. Re-authored component props inside an
|
|
583
|
+
// iteration need a page refresh to take effect (known HMR limitation).
|
|
584
|
+
const tplEls = [];
|
|
585
|
+
for (let i = siStart + 1; i < sEnd; i++) {
|
|
586
|
+
if (srcNodes[i].nodeType === ELEMENT) tplEls.push(srcNodes[i]);
|
|
587
|
+
}
|
|
588
|
+
if (!tplEls.length) return;
|
|
589
|
+
|
|
590
|
+
let instanceIdx = 0;
|
|
591
|
+
for (let i = liStart + 1; i < liEnd; i++) {
|
|
592
|
+
const liveEl = liveNodes[i];
|
|
593
|
+
if (liveEl.nodeType !== ELEMENT) continue;
|
|
594
|
+
const tplEl = tplEls[instanceIdx % tplEls.length];
|
|
595
|
+
instanceIdx++;
|
|
596
|
+
if (liveEl.tagName !== tplEl.tagName) continue; // shape changed; skip
|
|
597
|
+
reconcileAttributes(liveEl, tplEl, log);
|
|
598
|
+
reconcileChildren(liveEl, [...tplEl.childNodes], log, true);
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// Public entry point. Parses `source` (HTML string or DOM element) into an
|
|
603
|
+
// inert <template>, then walks `target`'s children against it.
|
|
604
|
+
//
|
|
605
|
+
// Returns a Promise that resolves to a mutation summary object once vibe's
|
|
606
|
+
// MutationObserver has settled. The summary is `{ text, attr, insert,
|
|
607
|
+
// remove, replace, changes }` — counts plus a human-readable list of what
|
|
608
|
+
// was changed (useful for HMR debug logging).
|
|
609
|
+
export const reconcile = (target, source) => {
|
|
610
|
+
const live = typeof target === 'string' ? document.querySelector(target) : target;
|
|
611
|
+
if (!live) return Promise.resolve({ text: 0, attr: 0, insert: 0, remove: 0, replace: 0, changes: [] });
|
|
612
|
+
|
|
613
|
+
const log = { text: 0, attr: 0, insert: 0, remove: 0, replace: 0, changes: [] };
|
|
614
|
+
const tpl = document.createElement('template');
|
|
615
|
+
tpl.innerHTML = typeof source === 'string' ? source : source.outerHTML;
|
|
616
|
+
reconcileChildren(live, [...tpl.content.childNodes], log);
|
|
617
|
+
|
|
618
|
+
return new Promise((resolve) => {
|
|
619
|
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve(log)));
|
|
620
|
+
});
|
|
621
|
+
};
|
package/runtime/state.js
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
// Track which objects are already proxied to avoid double-wrapping
|
|
2
2
|
const proxyCache = new WeakMap();
|
|
3
3
|
|
|
4
|
+
// Batching: collect mutations and flush once per microtask
|
|
5
|
+
let pendingFlush = false;
|
|
6
|
+
let flushCallback = null;
|
|
7
|
+
const changedProps = new Set();
|
|
8
|
+
|
|
9
|
+
const scheduleFlush = () => {
|
|
10
|
+
if (!pendingFlush) {
|
|
11
|
+
pendingFlush = true;
|
|
12
|
+
queueMicrotask(() => {
|
|
13
|
+
pendingFlush = false;
|
|
14
|
+
const props = [...changedProps];
|
|
15
|
+
changedProps.clear();
|
|
16
|
+
if (flushCallback) flushCallback(props);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
4
21
|
// Deep proxy: recursively wrap nested objects and arrays
|
|
5
22
|
const createDeepProxy = (target, rerender, rootState = null, rootProp = null) => {
|
|
6
23
|
// For root level, rootState is the target itself
|
|
7
24
|
if (rootState === null) {
|
|
8
25
|
rootState = target;
|
|
26
|
+
flushCallback = (props) => rerender(props);
|
|
9
27
|
}
|
|
10
28
|
|
|
11
29
|
// Check cache first
|
|
@@ -19,13 +37,11 @@ const createDeepProxy = (target, rerender, rootState = null, rootProp = null) =>
|
|
|
19
37
|
|
|
20
38
|
// Only trigger rerender if value actually changed
|
|
21
39
|
if (oldValue !== value) {
|
|
22
|
-
// Perform the mutation
|
|
23
40
|
const ref = Reflect.set(obj, prop, value);
|
|
24
41
|
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
rerender({ [changedProp]: rootState[changedProp] }, null);
|
|
42
|
+
// Track which root-level property changed (for selective extraction)
|
|
43
|
+
changedProps.add(rootProp || prop);
|
|
44
|
+
scheduleFlush();
|
|
29
45
|
|
|
30
46
|
return ref;
|
|
31
47
|
}
|
|
@@ -37,8 +53,11 @@ const createDeepProxy = (target, rerender, rootState = null, rootProp = null) =>
|
|
|
37
53
|
get(target, prop) {
|
|
38
54
|
const value = Reflect.get(target, prop);
|
|
39
55
|
|
|
40
|
-
// Don't proxy non-objects, functions, or
|
|
41
|
-
|
|
56
|
+
// Don't proxy non-objects, functions, null, or Promises.
|
|
57
|
+
// Promises aren't reactive state; wrapping them would also violate
|
|
58
|
+
// the Proxy invariant when exposed as non-writable/non-configurable
|
|
59
|
+
// properties (e.g. $.ready).
|
|
60
|
+
if (value === null || typeof value !== 'object' || typeof value === 'function' || value instanceof Promise) {
|
|
42
61
|
return value;
|
|
43
62
|
}
|
|
44
63
|
|