@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.
@@ -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; // see orphan-script note above
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; // empty branch; nothing to walk
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; // no matching branch; leave opaque
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; // shape changed; skip
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: [] });
@@ -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;