@ape-egg/vibe 4.0.0 → 4.1.1
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/README.md +3 -1
- package/boot.js +10 -9
- package/component.js +0 -29
- package/hot-module-refresh.js +0 -0
- package/index.js +0 -28
- package/package.json +3 -2
- package/runtime/_vibe-compiled-iteration-batch.js +0 -56
- package/runtime/affected.js +6 -189
- package/runtime/cleanup.js +4 -35
- package/runtime/component-cache.js +0 -53
- package/runtime/component.js +7 -401
- package/runtime/conditionals.js +1 -143
- package/runtime/constants.js +19 -82
- package/runtime/debug.js +22 -47
- package/runtime/dispatch.js +0 -46
- package/runtime/hydrate.js +10 -129
- package/runtime/index.js +7 -365
- package/runtime/iterate.js +21 -579
- package/runtime/iteration-utils.js +18 -71
- package/runtime/loop-scope.js +0 -58
- package/runtime/manifest.js +0 -27
- package/runtime/parse.js +2 -116
- package/runtime/pre-compiled-iterations.js +3 -51
- package/runtime/pre-compiled-manifest.js +6 -169
- package/runtime/raw-html.js +0 -5
- package/runtime/reconcile.js +4 -159
- package/runtime/staging.js +0 -57
- package/runtime/state.js +0 -61
- package/runtime/this-scope.js +0 -17
- package/runtime/tracking.js +0 -65
- package/runtime/utils.js +1 -144
- package/runtime/vibe-css.js +54 -0
- package/spa.js +0 -76
- package/vibe.css +22 -44
package/runtime/reconcile.js
CHANGED
|
@@ -1,17 +1,5 @@
|
|
|
1
1
|
import { ITERATION_START_REGEX, CONDITIONAL_START_REGEX } from './constants.js';
|
|
2
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
3
|
const COMMENT = 8;
|
|
16
4
|
const ELEMENT = 1;
|
|
17
5
|
const TEXT = 3;
|
|
@@ -29,18 +17,10 @@ const isComponentWrapper = (n) =>
|
|
|
29
17
|
n.nodeType === ELEMENT &&
|
|
30
18
|
(n.tagName === 'COMPONENT' || (n.tagName === 'DIV' && n.classList?.contains('component')));
|
|
31
19
|
|
|
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
20
|
const isSlotElement = (n) =>
|
|
39
21
|
n.nodeType === ELEMENT &&
|
|
40
22
|
(n.tagName === 'SLOT' || (n.tagName === 'DIV' && n.classList?.contains('slot')));
|
|
41
23
|
|
|
42
|
-
// Two nodes "look matchable" — same nodeType, same tag for elements. Used
|
|
43
|
-
// by the realignment lookahead below.
|
|
44
24
|
const nodesMatch = (a, b) => {
|
|
45
25
|
if (!a || !b) return false;
|
|
46
26
|
if (a.nodeType !== b.nodeType) return false;
|
|
@@ -48,11 +28,6 @@ const nodesMatch = (a, b) => {
|
|
|
48
28
|
return true;
|
|
49
29
|
};
|
|
50
30
|
|
|
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
31
|
const REALIGN_WINDOW = 4;
|
|
57
32
|
const findRealignment = (liveNodes, li, sourceNodes, si) => {
|
|
58
33
|
for (let total = 1; total <= 2 * REALIGN_WINDOW; total++) {
|
|
@@ -68,10 +43,6 @@ const findRealignment = (liveNodes, li, sourceNodes, si) => {
|
|
|
68
43
|
return null;
|
|
69
44
|
};
|
|
70
45
|
|
|
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
46
|
const trimWsEdges = (nodes) => {
|
|
76
47
|
let start = 0;
|
|
77
48
|
let end = nodes.length;
|
|
@@ -80,12 +51,6 @@ const trimWsEdges = (nodes) => {
|
|
|
80
51
|
return nodes.slice(start, end);
|
|
81
52
|
};
|
|
82
53
|
|
|
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
54
|
const firstElementChildOf = (parent) => {
|
|
90
55
|
for (const n of parent.children || []) return n;
|
|
91
56
|
return null;
|
|
@@ -106,14 +71,6 @@ const findOwnSlot = (el, srcChildren) => {
|
|
|
106
71
|
|
|
107
72
|
const hasBinding = (s) => /@\[.+?\]/.test(s);
|
|
108
73
|
|
|
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
74
|
const boundSourceCache = new WeakMap();
|
|
118
75
|
|
|
119
76
|
const updateBoundText = (live, newSrc, parent, log) => {
|
|
@@ -121,8 +78,6 @@ const updateBoundText = (live, newSrc, parent, log) => {
|
|
|
121
78
|
boundSourceCache.set(live, newSrc);
|
|
122
79
|
if (oldSrc === undefined || oldSrc === newSrc) return;
|
|
123
80
|
|
|
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
81
|
const oldBindings = oldSrc.match(/@\[[^\]]+\]/g) || [];
|
|
127
82
|
const newBindings = newSrc.match(/@\[[^\]]+\]/g) || [];
|
|
128
83
|
if (oldBindings.length !== newBindings.length) return;
|
|
@@ -130,11 +85,9 @@ const updateBoundText = (live, newSrc, parent, log) => {
|
|
|
130
85
|
if (oldBindings[i] !== newBindings[i]) return;
|
|
131
86
|
}
|
|
132
87
|
|
|
133
|
-
// Split each source by binding markers → static segments around each binding.
|
|
134
88
|
const oldStatic = oldSrc.split(/@\[[^\]]+\]/);
|
|
135
89
|
const newStatic = newSrc.split(/@\[[^\]]+\]/);
|
|
136
90
|
|
|
137
|
-
// Extract resolved values from live by stripping the old statics.
|
|
138
91
|
const liveText = live.textContent;
|
|
139
92
|
const values = [];
|
|
140
93
|
let pos = 0;
|
|
@@ -166,9 +119,6 @@ const updateBoundText = (live, newSrc, parent, log) => {
|
|
|
166
119
|
log.changes.push(`bound text in ${describe(parent)}: ${JSON.stringify(result.slice(0, 60))}`);
|
|
167
120
|
};
|
|
168
121
|
|
|
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
122
|
const findRegionEnd = (nodes, startIdx, endLimit) => {
|
|
173
123
|
const limit = endLimit ?? nodes.length;
|
|
174
124
|
let depth = 1;
|
|
@@ -183,8 +133,6 @@ const findRegionEnd = (nodes, startIdx, endLimit) => {
|
|
|
183
133
|
return limit - 1;
|
|
184
134
|
};
|
|
185
135
|
|
|
186
|
-
// Find the depth-0 `<!-- else -->` marker between an `<!-- if -->` and its
|
|
187
|
-
// `<!-- /if -->`. Returns -1 if there's no else branch.
|
|
188
136
|
const findElseMarker = (nodes, startIdx, endIdx) => {
|
|
189
137
|
let depth = 0;
|
|
190
138
|
for (let i = startIdx; i < endIdx; i++) {
|
|
@@ -198,15 +146,10 @@ const findElseMarker = (nodes, startIdx, endIdx) => {
|
|
|
198
146
|
return -1;
|
|
199
147
|
};
|
|
200
148
|
|
|
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
149
|
const PRESERVED_ATTRS = new Set(['vibe-fouc']);
|
|
205
150
|
const PRESERVED_CLASSES = new Set(['vibe-fouc']);
|
|
206
151
|
const isPreservedAttr = (name) => name.startsWith('data-vibe-') || PRESERVED_ATTRS.has(name);
|
|
207
152
|
|
|
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
153
|
const mergeClass = (srcValue, live) => {
|
|
211
154
|
const tokens = new Set(srcValue.split(/\s+/).filter(Boolean));
|
|
212
155
|
for (const t of live.classList) {
|
|
@@ -215,7 +158,6 @@ const mergeClass = (srcValue, live) => {
|
|
|
215
158
|
return [...tokens].join(' ');
|
|
216
159
|
};
|
|
217
160
|
|
|
218
|
-
// A description string for an element, for log readability.
|
|
219
161
|
const describe = (el) => {
|
|
220
162
|
if (!el || el.nodeType !== ELEMENT) return String(el?.nodeName || el);
|
|
221
163
|
const id = el.id ? '#' + el.id : '';
|
|
@@ -224,17 +166,9 @@ const describe = (el) => {
|
|
|
224
166
|
};
|
|
225
167
|
|
|
226
168
|
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
169
|
let hasNameBinding = false;
|
|
235
170
|
for (const attr of src.attributes) {
|
|
236
171
|
if (attr.name.includes('@[')) { hasNameBinding = true; continue; }
|
|
237
|
-
// Source-bound attribute? hydrate owns its value — don't overwrite.
|
|
238
172
|
if (hasBinding(attr.value)) continue;
|
|
239
173
|
const next = attr.name === 'class' ? mergeClass(attr.value, live) : attr.value;
|
|
240
174
|
if (live.getAttribute(attr.name) !== next) {
|
|
@@ -247,12 +181,7 @@ const reconcileAttributes = (live, src, log) => {
|
|
|
247
181
|
for (const attr of [...live.attributes]) {
|
|
248
182
|
if (src.hasAttribute(attr.name)) continue;
|
|
249
183
|
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
184
|
if (src.hasAttribute('data-' + attr.name)) continue;
|
|
255
|
-
// Source omitted `class` entirely — keep only preserved tokens (or drop).
|
|
256
185
|
if (attr.name === 'class') {
|
|
257
186
|
const kept = [...live.classList].filter((t) => PRESERVED_CLASSES.has(t));
|
|
258
187
|
if (kept.length) live.setAttribute('class', kept.join(' '));
|
|
@@ -267,17 +196,6 @@ const reconcileAttributes = (live, src, log) => {
|
|
|
267
196
|
}
|
|
268
197
|
};
|
|
269
198
|
|
|
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
199
|
const reconcileRange = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart, sEnd, log, insideIteration = false) => {
|
|
282
200
|
let li = liStart;
|
|
283
201
|
let si = siStart;
|
|
@@ -301,11 +219,6 @@ const reconcileRange = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart
|
|
|
301
219
|
continue;
|
|
302
220
|
}
|
|
303
221
|
|
|
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
222
|
const srcIsScript = src?.nodeType === ELEMENT && src.tagName === 'SCRIPT';
|
|
310
223
|
const liveIsScript = live?.nodeType === ELEMENT && live.tagName === 'SCRIPT';
|
|
311
224
|
if (srcIsScript && !liveIsScript) { si++; continue; }
|
|
@@ -314,7 +227,6 @@ const reconcileRange = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart
|
|
|
314
227
|
const liveIsRegion = isIterationStart(live) || isConditionalStart(live);
|
|
315
228
|
const srcIsRegion = isIterationStart(src) || isConditionalStart(src);
|
|
316
229
|
|
|
317
|
-
// Both pointers at a Vibe-owned region — recurse based on kind.
|
|
318
230
|
if (liveIsRegion && srcIsRegion) {
|
|
319
231
|
const liveEnd = findRegionEnd(liveNodes, li, liEnd);
|
|
320
232
|
const srcEnd = findRegionEnd(srcNodes, si, sEnd);
|
|
@@ -323,12 +235,10 @@ const reconcileRange = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart
|
|
|
323
235
|
} else if (isIterationStart(live) && isIterationStart(src)) {
|
|
324
236
|
reconcileIterationRegion(liveParent, liveNodes, li, liveEnd, srcNodes, si, srcEnd, log);
|
|
325
237
|
}
|
|
326
|
-
// Mismatched region kinds (if vs each) → opaque, skip both.
|
|
327
238
|
li = liveEnd + 1;
|
|
328
239
|
si = srcEnd + 1;
|
|
329
240
|
continue;
|
|
330
241
|
}
|
|
331
|
-
// Source removed a region that's still live — drop the live region.
|
|
332
242
|
if (liveIsRegion) {
|
|
333
243
|
const end = findRegionEnd(liveNodes, li, liEnd);
|
|
334
244
|
for (let k = li; k <= end; k++) {
|
|
@@ -339,8 +249,6 @@ const reconcileRange = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart
|
|
|
339
249
|
li = end + 1;
|
|
340
250
|
continue;
|
|
341
251
|
}
|
|
342
|
-
// Source added a new region — insert it wholesale; MutationObserver
|
|
343
|
-
// will pick up the new comments + children and process them.
|
|
344
252
|
if (srcIsRegion) {
|
|
345
253
|
const end = findRegionEnd(srcNodes, si, sEnd);
|
|
346
254
|
for (let k = si; k <= end; k++) {
|
|
@@ -352,32 +260,13 @@ const reconcileRange = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart
|
|
|
352
260
|
continue;
|
|
353
261
|
}
|
|
354
262
|
|
|
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
263
|
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
264
|
if (insideIteration) {
|
|
373
265
|
li++;
|
|
374
266
|
si++;
|
|
375
267
|
continue;
|
|
376
268
|
}
|
|
377
269
|
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
270
|
const srcProps = {};
|
|
382
271
|
for (const a of src.attributes) {
|
|
383
272
|
if (a.name === 'vibe-fouc') continue;
|
|
@@ -405,10 +294,6 @@ const reconcileRange = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart
|
|
|
405
294
|
fresh.setAttribute('vibe-fouc', '');
|
|
406
295
|
const slotHtml = src.innerHTML.trim();
|
|
407
296
|
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
297
|
fresh._vibePluginSlot = slotHtml;
|
|
413
298
|
live.replaceWith(fresh);
|
|
414
299
|
log.replace++;
|
|
@@ -429,16 +314,12 @@ const reconcileRange = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart
|
|
|
429
314
|
continue;
|
|
430
315
|
}
|
|
431
316
|
|
|
432
|
-
// <slot> elements are opaque (slot content is caller-owned).
|
|
433
317
|
if (isSlotElement(live) && isSlotElement(src)) {
|
|
434
318
|
li++;
|
|
435
319
|
si++;
|
|
436
320
|
continue;
|
|
437
321
|
}
|
|
438
322
|
|
|
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
323
|
if (live.nodeType === TEXT && src.nodeType === TEXT) {
|
|
443
324
|
if (hasBinding(src.textContent)) {
|
|
444
325
|
updateBoundText(live, src.textContent, liveParent, log);
|
|
@@ -453,7 +334,6 @@ const reconcileRange = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart
|
|
|
453
334
|
continue;
|
|
454
335
|
}
|
|
455
336
|
|
|
456
|
-
// Plain comment nodes (non-region) — update text if changed.
|
|
457
337
|
if (live.nodeType === COMMENT && src.nodeType === COMMENT) {
|
|
458
338
|
if (live.textContent !== src.textContent) {
|
|
459
339
|
live.textContent = src.textContent;
|
|
@@ -464,7 +344,6 @@ const reconcileRange = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart
|
|
|
464
344
|
continue;
|
|
465
345
|
}
|
|
466
346
|
|
|
467
|
-
// Element pair, same tag → reconcile in place. Identity preserved.
|
|
468
347
|
if (live.nodeType === ELEMENT && src.nodeType === ELEMENT &&
|
|
469
348
|
live.tagName === src.tagName) {
|
|
470
349
|
reconcileAttributes(live, src, log);
|
|
@@ -474,14 +353,11 @@ const reconcileRange = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart
|
|
|
474
353
|
continue;
|
|
475
354
|
}
|
|
476
355
|
|
|
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
356
|
const align = findRealignment(liveNodes, li, srcNodes, si);
|
|
481
357
|
if (align && li + align.dl < liEnd && si + align.ds < sEnd) {
|
|
482
358
|
for (let i = 0; i < align.dl; i++) {
|
|
483
359
|
const n = liveNodes[li + i];
|
|
484
|
-
if (n.nodeType === ELEMENT && n.tagName === 'SCRIPT') continue;
|
|
360
|
+
if (n.nodeType === ELEMENT && n.tagName === 'SCRIPT') continue;
|
|
485
361
|
log.remove++;
|
|
486
362
|
log.changes.push(`remove (extra) ${describe(n)} from ${describe(liveParent)}`);
|
|
487
363
|
n.remove();
|
|
@@ -499,7 +375,6 @@ const reconcileRange = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart
|
|
|
499
375
|
continue;
|
|
500
376
|
}
|
|
501
377
|
|
|
502
|
-
// Truly mismatched (no realignment possible) — replace.
|
|
503
378
|
log.replace++;
|
|
504
379
|
log.changes.push(`replace ${describe(live)} → ${describe(src)} in ${describe(liveParent)}`);
|
|
505
380
|
live.replaceWith(src.cloneNode(true));
|
|
@@ -513,25 +388,18 @@ const reconcileChildren = (liveParent, sourceNodes, log, insideIteration = false
|
|
|
513
388
|
reconcileRange(liveParent, liveNodes, 0, liveNodes.length, sourceNodes, 0, sourceNodes.length, log, insideIteration);
|
|
514
389
|
};
|
|
515
390
|
|
|
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
391
|
const reconcileConditionalRegion = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart, sEnd, log, insideIteration = false) => {
|
|
522
|
-
// Source branches.
|
|
523
392
|
const elseIdx = findElseMarker(srcNodes, siStart + 1, sEnd);
|
|
524
393
|
const ifStart = siStart + 1;
|
|
525
394
|
const ifEnd = elseIdx === -1 ? sEnd : elseIdx;
|
|
526
395
|
const elseStart = elseIdx === -1 ? sEnd : elseIdx + 1;
|
|
527
396
|
const elseEnd = sEnd;
|
|
528
397
|
|
|
529
|
-
// First element on each side decides which branch is live.
|
|
530
398
|
let liveFirstEl = null;
|
|
531
399
|
for (let i = liStart + 1; i < liEnd; i++) {
|
|
532
400
|
if (liveNodes[i].nodeType === ELEMENT) { liveFirstEl = liveNodes[i]; break; }
|
|
533
401
|
}
|
|
534
|
-
if (!liveFirstEl) return;
|
|
402
|
+
if (!liveFirstEl) return;
|
|
535
403
|
|
|
536
404
|
let chosenStart = -1;
|
|
537
405
|
let chosenEnd = -1;
|
|
@@ -553,7 +421,7 @@ const reconcileConditionalRegion = (liveParent, liveNodes, liStart, liEnd, srcNo
|
|
|
553
421
|
}
|
|
554
422
|
}
|
|
555
423
|
}
|
|
556
|
-
if (chosenStart === -1) return;
|
|
424
|
+
if (chosenStart === -1) return;
|
|
557
425
|
|
|
558
426
|
reconcileRange(
|
|
559
427
|
liveParent, liveNodes,
|
|
@@ -564,23 +432,7 @@ const reconcileConditionalRegion = (liveParent, liveNodes, liStart, liEnd, srcNo
|
|
|
564
432
|
);
|
|
565
433
|
};
|
|
566
434
|
|
|
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
435
|
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
436
|
const tplEls = [];
|
|
585
437
|
for (let i = siStart + 1; i < sEnd; i++) {
|
|
586
438
|
if (srcNodes[i].nodeType === ELEMENT) tplEls.push(srcNodes[i]);
|
|
@@ -593,19 +445,12 @@ const reconcileIterationRegion = (liveParent, liveNodes, liStart, liEnd, srcNode
|
|
|
593
445
|
if (liveEl.nodeType !== ELEMENT) continue;
|
|
594
446
|
const tplEl = tplEls[instanceIdx % tplEls.length];
|
|
595
447
|
instanceIdx++;
|
|
596
|
-
if (liveEl.tagName !== tplEl.tagName) continue;
|
|
448
|
+
if (liveEl.tagName !== tplEl.tagName) continue;
|
|
597
449
|
reconcileAttributes(liveEl, tplEl, log);
|
|
598
450
|
reconcileChildren(liveEl, [...tplEl.childNodes], log, true);
|
|
599
451
|
}
|
|
600
452
|
};
|
|
601
453
|
|
|
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
454
|
export const reconcile = (target, source) => {
|
|
610
455
|
const live = typeof target === 'string' ? document.querySelector(target) : target;
|
|
611
456
|
if (!live) return Promise.resolve({ text: 0, attr: 0, insert: 0, remove: 0, replace: 0, changes: [] });
|
package/runtime/staging.js
CHANGED
|
@@ -1,24 +1,3 @@
|
|
|
1
|
-
// Navigation staging — the one-paint commit machinery behind reactive-src /
|
|
2
|
-
// keyed remounts, owned in one place. A remount marks the mounted wrapper
|
|
3
|
-
// OUTGOING: it keeps the screen — visible, styled, frozen — while the
|
|
4
|
-
// incoming wrapper hydrates as a display:none sibling ([vibe-staged]). The
|
|
5
|
-
// commit swaps the two and replays parked ancestor bindings in one
|
|
6
|
-
// synchronous step: navigation paints exactly once, never an unstyled or
|
|
7
|
-
// blank frame between.
|
|
8
|
-
//
|
|
9
|
-
// Every consumer answers its question through THIS module:
|
|
10
|
-
// isOutgoing(el) tree walks — skip collecting under a frozen wrapper
|
|
11
|
-
// outgoingRootOf(n) dispatch — is this subscriber inside a frozen page?
|
|
12
|
-
// parkRootFor(el) hydrate — is a frozen page inside this element? then
|
|
13
|
-
// park the binding (it is the styling context AROUND
|
|
14
|
-
// the outlet — flipping it mid-stage un-styles the
|
|
15
|
-
// visible page) and let the commit replay it
|
|
16
|
-
// stage/commit/abandon component.js's mount finalize drives transitions
|
|
17
|
-
|
|
18
|
-
// The mount pipeline replaces wrappers (finalize's replaceWith), leaving a
|
|
19
|
-
// `_vibeReplacedBy` link behind. Trees and clone lists keep ORIGINAL nodes —
|
|
20
|
-
// follow the chain to the live one, compressing so intermediate detached
|
|
21
|
-
// wrappers stay collectable.
|
|
22
1
|
export const liveNode = (node) => {
|
|
23
2
|
let live = node;
|
|
24
3
|
while (live && live._vibeReplacedBy) live = live._vibeReplacedBy;
|
|
@@ -26,7 +5,6 @@ export const liveNode = (node) => {
|
|
|
26
5
|
return live;
|
|
27
6
|
};
|
|
28
7
|
|
|
29
|
-
// Wrappers whose mounted content is outgoing — a remount is in flight.
|
|
30
8
|
export const activeOutgoingRoots = new Set();
|
|
31
9
|
|
|
32
10
|
export const isOutgoing = (element) => !!(element && liveNode(element)._vibeOutgoing);
|
|
@@ -41,11 +19,6 @@ export const releaseOutgoing = (el) => {
|
|
|
41
19
|
activeOutgoingRoots.delete(el);
|
|
42
20
|
};
|
|
43
21
|
|
|
44
|
-
// The outgoing root STRICTLY containing `node`, or null. The root's own
|
|
45
|
-
// src/key/prop bindings stay live (a rapid next navigation must re-trigger);
|
|
46
|
-
// everything beneath it is replaced wholesale at the swap, so re-rendering
|
|
47
|
-
// it against post-navigation state is pure waste — and was the visible
|
|
48
|
-
// mid-navigation collapse.
|
|
49
22
|
export const outgoingRootOf = (node) => {
|
|
50
23
|
if (!activeOutgoingRoots.size || !node) return null;
|
|
51
24
|
for (const root of activeOutgoingRoots) {
|
|
@@ -54,9 +27,6 @@ export const outgoingRootOf = (node) => {
|
|
|
54
27
|
return null;
|
|
55
28
|
};
|
|
56
29
|
|
|
57
|
-
// The outgoing root strictly INSIDE `element`, or null — the park direction:
|
|
58
|
-
// `element` is the styling context around the outlet (the game's
|
|
59
|
-
// `<page @[page.name]>`). Zero cost when nothing is staging.
|
|
60
30
|
export const parkRootFor = (element) => {
|
|
61
31
|
if (!activeOutgoingRoots.size || !element) return null;
|
|
62
32
|
for (const root of activeOutgoingRoots) {
|
|
@@ -65,9 +35,6 @@ export const parkRootFor = (element) => {
|
|
|
65
35
|
return null;
|
|
66
36
|
};
|
|
67
37
|
|
|
68
|
-
// Parked updates live on the outgoing wrapper (Map<element, Map<key, apply>>)
|
|
69
|
-
// keyed by element + binding so rapid flushes during staging keep only the
|
|
70
|
-
// latest value; the commit replays them in its synchronous swap step.
|
|
71
38
|
export const parkBinding = (root, element, key, apply) => {
|
|
72
39
|
const parked = (root._vibeParkedBindings ??= new Map());
|
|
73
40
|
let perElement = parked.get(element);
|
|
@@ -84,27 +51,16 @@ const replayParked = (old) => {
|
|
|
84
51
|
old._vibeParkedBindings = null;
|
|
85
52
|
};
|
|
86
53
|
|
|
87
|
-
// A fetched-component host: `<component>` or `<div class="component">`.
|
|
88
54
|
export const isComponentWrapper = (el) =>
|
|
89
55
|
el.nodeName === 'COMPONENT' ||
|
|
90
56
|
(el.nodeName === 'DIV' && el.classList?.contains('component'));
|
|
91
57
|
|
|
92
|
-
// A flush entry that (re)mounts: the wrapper's own src/key binding. These
|
|
93
|
-
// process FLUSH-WIDE FIRST — they register the outgoing roots every other
|
|
94
|
-
// entry's freeze-skip and parking decisions depend on. (Tree-walk order gave
|
|
95
|
-
// the walk this for free within one pass; dispatch batches must hoist.)
|
|
96
58
|
export const isRemountTrigger = (entry) =>
|
|
97
59
|
entry.type === 'attribute' &&
|
|
98
60
|
(entry.attrName === 'src' || entry.attrName === 'key') &&
|
|
99
61
|
!!entry.element &&
|
|
100
62
|
isComponentWrapper(entry.element);
|
|
101
63
|
|
|
102
|
-
// ————— staged swap transitions (driven by component.js finalize) —————
|
|
103
|
-
|
|
104
|
-
// Mount landed for an outgoing wrapper: stage the incoming wrapper, don't
|
|
105
|
-
// swap. A superseded staged wrapper (rapid re-navigation) was never visible:
|
|
106
|
-
// drop it immediately and inherit the ORIGINAL visible page as this mount's
|
|
107
|
-
// commit target.
|
|
108
64
|
export const stageIncoming = (el, newWrapper) => {
|
|
109
65
|
if (el.hasAttribute('vibe-staged')) {
|
|
110
66
|
newWrapper._vibeCommitOld = el._vibeCommitOld;
|
|
@@ -114,16 +70,11 @@ export const stageIncoming = (el, newWrapper) => {
|
|
|
114
70
|
} else {
|
|
115
71
|
newWrapper._vibeCommitOld = el;
|
|
116
72
|
newWrapper.setAttribute('vibe-staged', '');
|
|
117
|
-
// The outgoing wrapper is no longer a mount request — with the src
|
|
118
|
-
// attribute still on it, every processComponent rescan would treat it as
|
|
119
|
-
// unresolved and mount a second copy while it holds the screen.
|
|
120
73
|
el.removeAttribute('src');
|
|
121
74
|
el.after(newWrapper);
|
|
122
75
|
}
|
|
123
76
|
};
|
|
124
77
|
|
|
125
|
-
// Atomic visual commit: old page out, parked styling-context bindings
|
|
126
|
-
// applied, new page revealed — one synchronous block, one paint.
|
|
127
78
|
export const commitStaged = (newWrapper) => {
|
|
128
79
|
const old = newWrapper._vibeCommitOld;
|
|
129
80
|
if (old) {
|
|
@@ -135,14 +86,6 @@ export const commitStaged = (newWrapper) => {
|
|
|
135
86
|
newWrapper.removeAttribute('vibe-staged');
|
|
136
87
|
};
|
|
137
88
|
|
|
138
|
-
// Incoming wrapper died before its commit (unmounted mid-hydration, or
|
|
139
|
-
// removed by something outside the staging machinery — $.reconcile on an
|
|
140
|
-
// ancestor, app code pruning the container): no swap is coming. Release the
|
|
141
|
-
// frozen old page — connected, it must resume updating; detached, the Set
|
|
142
|
-
// entry would pin the subtree forever — and reverse the replaced-by link so
|
|
143
|
-
// chains compressed onto the dead staged wrapper resolve back to the page
|
|
144
|
-
// that stayed (its src binding must act on IT again, or no later navigation
|
|
145
|
-
// can ever remount).
|
|
146
89
|
export const abandonStaged = (newWrapper) => {
|
|
147
90
|
const old = newWrapper._vibeCommitOld;
|
|
148
91
|
if (!old) return;
|