@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/runtime/debug.js CHANGED
@@ -1,14 +1,5 @@
1
1
  import { DEBUGGER_NAME } from './constants.js';
2
2
 
3
- // Loud-in-debug evaluation failures. A typo'd binding must not fail silently
4
- // while developing — "let it crash in development" — while production keeps
5
- // the silent-undefined contract (a broken binding renders nothing, the page
6
- // survives). Once per expression: a broken binding re-evaluates every flush
7
- // and would otherwise firehose the console.
8
- // The affected-walk deliberately evaluates expressions in speculative
9
- // contexts (row templates against global state, conservative "can't prove
10
- // unaffected" probes) where throwing is expected and self-recovering. Those
11
- // are engine business — only RENDER-path failures are author business.
12
3
  let speculativeDepth = 0;
13
4
  export const beginSpeculative = () => {
14
5
  speculativeDepth++;
@@ -24,51 +15,38 @@ export const reportEvalError = (expression, element, error) => {
24
15
  console.error(`[vibe] Binding evaluation failed: @[${expression}]`, error, element ?? '');
25
16
  };
26
17
 
27
- // Phase colors - synced with index.css debug phase colors
28
- // Muted palette based on comment green (#6a9955)
29
18
  const PHASE_COLORS = {
30
- Attached: 'oklch(0.55 0.02 250)', // slate (minimal saturation)
31
- Manifested: 'oklch(0.55 0.02 250)', // slate (minimal saturation)
32
- Observer: 'oklch(0.60 0.11 240)', // muted blue
33
- Cleanup: 'oklch(0.55 0.02 250)', // slate (minimal saturation)
34
- Parsed: 'oklch(0.62 0.12 50)', // muted orange
35
- Hydrated: 'oklch(0.60 0.12 340)', // muted pink
36
- Iterated: 'oklch(0.58 0.11 142)', // comment green (baseline)
37
- Evaluated: 'oklch(0.58 0.11 142)', // comment green (baseline)
38
- Fetched: 'oklch(0.59 0.12 307)', // muted purple
39
- 'Fet(ca)ched': 'oklch(0.59 0.12 307)', // same muted purple — a Fetch served from the runtime cache
40
- Mutation: 'oklch(0.60 0.11 240)', // muted blue
41
- Proxy: 'oklch(0.60 0.11 240)', // muted blue
42
- Hyperspeed: 'oklch(0.60 0.11 180)', // muted cyan
19
+ Attached: 'oklch(0.55 0.02 250)',
20
+ Manifested: 'oklch(0.55 0.02 250)',
21
+ Observer: 'oklch(0.60 0.11 240)',
22
+ Cleanup: 'oklch(0.55 0.02 250)',
23
+ Parsed: 'oklch(0.62 0.12 50)',
24
+ Hydrated: 'oklch(0.60 0.12 340)',
25
+ Iterated: 'oklch(0.58 0.11 142)',
26
+ Evaluated: 'oklch(0.58 0.11 142)',
27
+ Fetched: 'oklch(0.59 0.12 307)',
28
+ 'Fet(ca)ched': 'oklch(0.59 0.12 307)',
29
+ Mutation: 'oklch(0.60 0.11 240)',
30
+ Proxy: 'oklch(0.60 0.11 240)',
31
+ Hyperspeed: 'oklch(0.60 0.11 180)',
43
32
  };
44
33
 
45
- // Special colors for styled segments (Tailwind 500)
46
34
  const COLORS = {
47
- green: 'oklch(0.769 0.227 141.41)', // green-500
48
- red: 'oklch(0.637 0.237 27.33)', // red-500
49
- yellow: 'oklch(0.809 0.177 94.36)', // yellow-500
50
- pink: 'oklch(0.649 0.237 346.06)', // pink-500
51
- slate: 'oklch(0.556 0.016 256.85)', // slate-500
52
- commentGreen: 'oklch(0.58 0.11 142)', // muted green (same as Iterated/Evaluated)
35
+ green: 'oklch(0.769 0.227 141.41)',
36
+ red: 'oklch(0.637 0.237 27.33)',
37
+ yellow: 'oklch(0.809 0.177 94.36)',
38
+ pink: 'oklch(0.649 0.237 346.06)',
39
+ slate: 'oklch(0.556 0.016 256.85)',
40
+ commentGreen: 'oklch(0.58 0.11 142)',
53
41
  };
54
42
 
55
- /**
56
- * Debug logging helper for Vibe
57
- * Only logs when debug mode is enabled
58
- * @param {string} phase - The lifecycle phase (Attach, Parse, Hydrate, etc.)
59
- * @param {string|Array} message - The message to log (string or array of {text, colored: boolean, color: string})
60
- * @param {boolean} debug - Whether debug mode is enabled
61
- * @param {number} indent - Indentation level (0 = no indent, 1+ = nested operations)
62
- * @param {HTMLElement} element - Optional DOM element to log (becomes clickable in console)
63
- */
64
43
  export const debugLog = (phase, message, debug = false, indent = 0, element = null) => {
65
44
  if (!debug) return;
66
45
 
67
- const phaseBracket = `[${phase}] `.padEnd(14, ' '); // Pad to 14 chars (longest is "[Fet(ca)ched] ")
46
+ const phaseBracket = `[${phase}] `.padEnd(14, ' ');
68
47
  const indentStr = indent > 0 ? ' '.repeat(indent) + '├─ ' : '';
69
48
  const phaseColor = PHASE_COLORS[phase] || 'oklch(0.55 0.02 250)';
70
49
 
71
- // Handle array of styled segments
72
50
  if (Array.isArray(message)) {
73
51
  let formatStr = `%c${phaseBracket}%c${DEBUGGER_NAME}%c ${indentStr}`;
74
52
  const styles = [
@@ -79,7 +57,6 @@ export const debugLog = (phase, message, debug = false, indent = 0, element = nu
79
57
 
80
58
  message.forEach((segment) => {
81
59
  formatStr += '%c' + segment.text;
82
- // Support both 'colored' (uses phase color) and 'color' (uses specific color)
83
60
  if (segment.color) {
84
61
  styles.push(`color: ${COLORS[segment.color] || segment.color}; font-weight: bold`);
85
62
  } else if (segment.colored) {
@@ -89,19 +66,17 @@ export const debugLog = (phase, message, debug = false, indent = 0, element = nu
89
66
  }
90
67
  });
91
68
 
92
- // Add element if provided (makes it clickable in console)
93
69
  if (element) {
94
70
  console.info(formatStr, ...styles, element);
95
71
  } else {
96
72
  console.info(formatStr, ...styles);
97
73
  }
98
74
  } else {
99
- // Simple string message
100
75
  if (element) {
101
76
  console.info(
102
77
  `%c${phaseBracket}%c${DEBUGGER_NAME}%c ${indentStr}${message}`,
103
78
  `color: ${phaseColor}; font-weight: bold`,
104
- 'color: oklch(0.70 0.01 250)', // light gray
79
+ 'color: oklch(0.70 0.01 250)',
105
80
  'color: inherit',
106
81
  element,
107
82
  );
@@ -109,7 +84,7 @@ export const debugLog = (phase, message, debug = false, indent = 0, element = nu
109
84
  console.info(
110
85
  `%c${phaseBracket}%c${DEBUGGER_NAME}%c ${indentStr}${message}`,
111
86
  `color: ${phaseColor}; font-weight: bold`,
112
- 'color: oklch(0.70 0.01 250)', // light gray
87
+ 'color: oklch(0.70 0.01 250)',
113
88
  'color: inherit',
114
89
  );
115
90
  }
@@ -1,12 +1,3 @@
1
- // Subscription dispatch — subscribe mode's flush (phase 2 of auto-tracked
2
- // subscriptions). Where the walk answers "what changed?" by evaluating the
3
- // world, dispatch already knows: the flush's changed keys select their
4
- // subscribers from the reverse index, each re-evaluates in document order
5
- // through the SAME patch machinery the walk uses (hydrate's entry
6
- // dispatcher, mountBranch, updateIteration) and re-records its dependencies
7
- // while doing so. The walk itself retires to mount duty — first hydration,
8
- // processMutations, branch mounts — where it keeps registering fresh
9
- // subscribers.
10
1
  import hydrate from './hydrate.js';
11
2
  import { updateIteration } from './iterate.js';
12
3
  import { dispatchConditional } from './conditionals.js';
@@ -21,10 +12,6 @@ import {
21
12
  } from './utils.js';
22
13
  import { resolvePath } from './iteration-utils.js';
23
14
 
24
- // Rebuild a row subscriber's evaluation scope against the CURRENT globals.
25
- // The scope it last evaluated in is a per-flush merged snapshot (stale
26
- // globals); only its alias overlay is durable — item/index identity is owned
27
- // by the iteration's diff, which rebuilds rows whenever it changes.
28
15
  export const scopeFor = (sub, base) => {
29
16
  const overlay = sub.lastScope ? scopedOverlayOf(sub.lastScope) : null;
30
17
  if (!overlay) return base;
@@ -33,18 +20,10 @@ export const scopeFor = (sub, base) => {
33
20
  return merged;
34
21
  };
35
22
 
36
- // The plan's iteration shallow-compare, generalized from the walk's
37
- // CALL_EXPR fresh-ref fix: same length + same item refs as the rendered
38
- // instances ⇒ the rows are already right (outer-state bindings inside rows
39
- // are their own subscribers). Ref inequality — including a reassigned array
40
- // after in-place mutation — falls through to the full diff.
41
23
  export const rowsShallowEqual = (instances, newArray) =>
42
24
  instances.length === newArray.length &&
43
25
  instances.every((inst, i) => inst.item === newArray[i]);
44
26
 
45
- // A text node's interpolation group renders all-or-nothing (hydrate
46
- // re-interpolates the whole span), so one dirty binding expands to entries
47
- // for every binding in its group.
48
27
  export const entriesForText = (sub, scope) => {
49
28
  const e = sub.entry;
50
29
  const group = e.binding?._group ?? [e.binding];
@@ -59,8 +38,6 @@ export const entriesForText = (sub, scope) => {
59
38
  }));
60
39
  };
61
40
 
62
- // Document-order sort over subscriber anchors — the walk is document-ordered
63
- // and the corpus expects that stability. Bit 4 = FOLLOWING, 2 = PRECEDING.
64
41
  const docOrder = (a, b) => {
65
42
  const x = a.anchor;
66
43
  const y = b.anchor;
@@ -73,7 +50,6 @@ const docOrder = (a, b) => {
73
50
 
74
51
  const dispatchIteration = (sub, currentState, previousState, manifest) => {
75
52
  const node = sub.node;
76
- // Not yet rendered — the mount path owns first render.
77
53
  if (!node.runtime.instances || !node.runtime.templateRemoved) return;
78
54
  const scope = scopeFor(sub, currentState);
79
55
  const parentEl = node.meta.startComment?.parentElement;
@@ -84,24 +60,12 @@ const dispatchIteration = (sub, currentState, previousState, manifest) => {
84
60
  endTracking();
85
61
  const instances = node.runtime.instances;
86
62
  const treeless = instances.length > 0 && !instances[0].tree;
87
- // Treeless (batch/compiled) rows skip the shallow guard — their HTML may
88
- // read non-array state; updateIteration's identical-HTML check absorbs
89
- // no-ops there.
90
63
  if (!treeless && Array.isArray(newArray) && rowsShallowEqual(instances, newArray)) return;
91
64
  updateIteration(node, scope, scopeFor(sub, previousState), manifest);
92
65
  };
93
66
 
94
- // The subscribe-mode flush: document-ordered dirty set → patch. Binding
95
- // entries batch through single hydrate calls between structural ops so
96
- // relative order is preserved (and hydrate's same-element input chaining
97
- // keeps working).
98
67
  export default (dirty, currentState, previousState, manifest) => {
99
68
  const subs = [...dirty].sort(docOrder);
100
- // Remount triggers (a wrapper's own src/key binding) hydrate FLUSH-WIDE
101
- // FIRST: they register the outgoing roots every later entry's freeze-skip
102
- // and parking depend on. The walk got this ordering from hydrate's
103
- // per-call two-pass; dispatch splits into multiple hydrate calls at
104
- // structural boundaries, so the hoist must span the whole flush.
105
69
  const triggers = [];
106
70
  const rest = [];
107
71
  for (const sub of subs) {
@@ -109,7 +73,6 @@ export default (dirty, currentState, previousState, manifest) => {
109
73
  unsubscribe(sub);
110
74
  continue;
111
75
  }
112
- // Already-frozen subtree (a staging window spanning flushes): skip.
113
76
  if (activeOutgoingRoots.size && outgoingRootOf(sub.anchor)) continue;
114
77
  (sub.entry && isRemountTrigger(sub.entry) ? triggers : rest).push(sub);
115
78
  }
@@ -130,19 +93,10 @@ export default (dirty, currentState, previousState, manifest) => {
130
93
  }
131
94
  };
132
95
  for (const sub of rest) {
133
- // Died mid-flush: an earlier structural op (iteration diff, branch flip)
134
- // tore this subscriber's DOM down after beginFlush selected it. The lazy
135
- // self-prune, at dispatch time.
136
96
  if (sub.anchor && !sub.anchor.isConnected) {
137
97
  unsubscribe(sub);
138
98
  continue;
139
99
  }
140
- // Frozen mid-flush: a trigger above marked this subscriber's page
141
- // outgoing — its DOM is replaced wholesale at the commit, so rendering
142
- // it against post-navigation state is the mid-navigation collapse the
143
- // freeze exists to prevent. Subscriptions stay; the swap's removal
144
- // prunes them lazily via isConnected. (Ancestor styling bindings park
145
- // inside hydrate — same seam the walk uses.)
146
100
  if (activeOutgoingRoots.size && outgoingRootOf(sub.anchor)) continue;
147
101
  if (sub.kind === 'conditional') {
148
102
  flushBatch();
@@ -10,14 +10,10 @@ import {
10
10
  scopedOverlayOf,
11
11
  } from './utils.js';
12
12
  import { RawHtml } from './raw-html.js';
13
+ import { markBoundValues } from './iteration-utils.js';
13
14
  import { beginTracking, endTracking } from './tracking.js';
14
15
  import { reportEvalError } from './debug.js';
15
16
 
16
- // Run one entry's evaluation inside a tracking window. The subscriber is
17
- // hosted on the walk's cached binding object (aff.binding) — stable per
18
- // binding across flushes, invalidated exactly when the binding cache
19
- // rebuilds. Entries without a binding host (hand-built lists from external
20
- // callers) evaluate untracked, exactly as before.
21
17
  const tracked = (aff, effectiveState, fn) => {
22
18
  const m = aff.binding;
23
19
  if (!m) return fn();
@@ -25,8 +21,8 @@ const tracked = (aff, effectiveState, fn) => {
25
21
  kind: aff.type ?? 'text',
26
22
  anchor: aff.textNode ?? aff.element,
27
23
  });
28
- sub.entry = aff; // latest entry shape — dispatch rebuilds scope at fire time
29
- sub.lastScope = effectiveState; // overlay source for dispatch-time rescoping
24
+ sub.entry = aff;
25
+ sub.lastScope = effectiveState;
30
26
  beginTracking(sub, overlayKeysOf(effectiveState));
31
27
  try {
32
28
  return fn();
@@ -38,34 +34,25 @@ const tracked = (aff, effectiveState, fn) => {
38
34
  const applyNameBinding = (aff, effectiveState) => {
39
35
  const { nameBinding, matchInner, element } = aff;
40
36
  try {
41
- // HTML lowercases attribute names, so we need case-insensitive lookup.
42
- // Try exact match first, then walk the path case-insensitively if the
43
- // exact lookup returned nothing — this recovers camelCase property
44
- // names in dotted paths like `<icon @[fx.convertsIcon]>` (arrives at
45
- // runtime as `@[fx.convertsicon]`).
46
37
  let attrName = evalInScope(matchInner, effectiveState, element);
47
38
 
48
39
  if (!attrName) {
49
40
  attrName = resolveCaseInsensitivePath(effectiveState, matchInner);
50
41
  }
51
42
 
52
- // Track multiple name bindings per element (need a map of binding -> evaluated attr)
53
43
  if (!element._vibeNameBindings) {
54
44
  element._vibeNameBindings = new Map();
55
45
  }
56
46
 
57
- // Remove the old evaluated attribute for this specific binding
58
47
  const oldAttrName = element._vibeNameBindings.get(nameBinding);
59
48
  if (oldAttrName) {
60
49
  element.removeAttribute(oldAttrName);
61
50
  }
62
51
 
63
- // Remove the binding attribute itself
64
52
  if (element.hasAttribute(nameBinding)) {
65
53
  element.removeAttribute(nameBinding);
66
54
  }
67
55
 
68
- // Set the new attribute (empty value for boolean-like attributes)
69
56
  if (attrName) {
70
57
  element.setAttribute(attrName, '');
71
58
  element._vibeNameBindings.set(nameBinding, attrName);
@@ -73,9 +60,6 @@ const applyNameBinding = (aff, effectiveState) => {
73
60
  element._vibeNameBindings.delete(nameBinding);
74
61
  }
75
62
 
76
- // A binding relocated into `data-vibe-namebind` (clone path) has served its
77
- // transport purpose once the real attribute is set — drop it so the rendered
78
- // DOM matches the batch path, which never emits it.
79
63
  if (element.hasAttribute('data-vibe-namebind')) {
80
64
  element.removeAttribute('data-vibe-namebind');
81
65
  }
@@ -87,10 +71,8 @@ const applyNameBinding = (aff, effectiveState) => {
87
71
  const applyAttributeBinding = (aff, effectiveState) => {
88
72
  const { attrName, attrValue, element } = aff;
89
73
  try {
90
- // Check if this is a pure binding (e.g., value="@[inputValue]")
91
74
  const isPureBinding = attrValue.match(PURE_BINDING_REGEX);
92
75
  const isDomProperty = DOM_PROPERTIES.includes(attrName);
93
- // Value attrs keep their string value; everything else is boolean-like (removed when falsy)
94
76
  const isValueAttr =
95
77
  VALUE_ATTRS.includes(attrName) ||
96
78
  attrName.startsWith('data-') ||
@@ -98,9 +80,6 @@ const applyAttributeBinding = (aff, effectiveState) => {
98
80
  attrName.startsWith('on');
99
81
 
100
82
  if (isDomProperty && isPureBinding) {
101
- // For DOM properties (value, checked, selected), set BOTH property AND attribute
102
- // Property: Fast runtime updates, what the user sees
103
- // Attribute: Truthful DOM representation, enables compiler snapshots via outerHTML
104
83
  const expr = isPureBinding[1];
105
84
  const value = evalInScope(expr, effectiveState, element);
106
85
  if (element[attrName] !== value) element[attrName] = value;
@@ -110,19 +89,11 @@ const applyAttributeBinding = (aff, effectiveState) => {
110
89
  if (element.getAttribute(attrName) !== str) element.setAttribute(attrName, str);
111
90
  }
112
91
  } else if (value) {
113
- // checked/selected are boolean — the truthful attribute form is
114
- // presence (empty) when truthy, absence when falsy. Stringifying
115
- // would leave checked="false", which is "checked" to CSS and HTML.
116
92
  if (element.getAttribute(attrName) !== '') element.setAttribute(attrName, '');
117
93
  } else if (element.hasAttribute(attrName)) {
118
94
  element.removeAttribute(attrName);
119
95
  }
120
96
  } else if (!isValueAttr && isPureBinding) {
121
- // Boolean-like attributes: add or remove based on truthiness.
122
- // Compare both presence AND value — initial hydration starts with
123
- // the raw `@[...]` binding text as the attribute value, so
124
- // `hasAttribute` alone isn't enough to know the canonical state is
125
- // already set.
126
97
  const expr = isPureBinding[1];
127
98
  const value = evalInScope(expr, effectiveState, element);
128
99
  if (value) {
@@ -133,7 +104,6 @@ const applyAttributeBinding = (aff, effectiveState) => {
133
104
  element.removeAttribute(attrName);
134
105
  }
135
106
  } else {
136
- // Value attribute - replace bindings with values
137
107
  const newValue = attrValue.replace(BINDING_REGEX, (_, expr) => {
138
108
  return evalInScope(expr, effectiveState, element);
139
109
  });
@@ -141,21 +111,13 @@ const applyAttributeBinding = (aff, effectiveState) => {
141
111
  element.setAttribute(attrName, newValue);
142
112
  }
143
113
  }
114
+ (element._vibeBoundAttrs ??= new Set()).add(attrName);
144
115
  } catch (e) {
145
116
  reportEvalError(attrValue, element, e);
146
117
  }
147
118
  };
148
119
 
149
120
  export default (affected, state, manifest = {}, oldState = {}) => {
150
- // Wrappers that entered a reactive-src remount DURING this pass (the src/key
151
- // handlers below). affected() collected this list before the outgoing flag
152
- // existed, so the outgoing subtree's entries are still in it — and this is
153
- // exactly the flush where route state flips. Applying them would paint the
154
- // outgoing page against the incoming route for the whole fetch+mount window
155
- // (the mid-navigation CSS collapse). Tree-walk order guarantees the
156
- // wrapper's own src/key entries precede its subtree's entries, so skipping
157
- // by ancestry here is airtight. Null until a remount actually happens —
158
- // zero cost on ordinary flushes.
159
121
  let outgoingRoots = null;
160
122
  const underOutgoingRoot = (aff) => {
161
123
  if (!outgoingRoots) return false;
@@ -168,12 +130,6 @@ export default (affected, state, manifest = {}, oldState = {}) => {
168
130
  return false;
169
131
  };
170
132
 
171
- // Two passes: wrapper src/key entries (remount triggers) first, everything
172
- // else second. A remount marks its wrapper outgoing and starts staging —
173
- // the freeze skip below and the ancestor-binding parking both depend on
174
- // that registration, and tree-walk order would otherwise process an
175
- // ancestor's bindings (the `<page @[page.name]>` styling context) before
176
- // the outlet entry that begins the staging they must park against.
177
133
  const remountTriggers = [];
178
134
  const ordinary = [];
179
135
  for (const aff of affected) {
@@ -183,31 +139,14 @@ export default (affected, state, manifest = {}, oldState = {}) => {
183
139
  const processEntry = (aff) => {
184
140
  if (underOutgoingRoot(aff)) return;
185
141
 
186
- // Torn down mid-pass: the entry's target was connected when the walk
187
- // collected it but left the document before this entry processed — an
188
- // earlier entry's teardown (an iteration emptying, a branch flip)
189
- // removed it. Same ordering hole as the outgoing-remount skip above.
190
- // Processing it would write to dead DOM and re-register a subscriber
191
- // the teardown just pruned, pinning the removed subtree. Entries that
192
- // were ALREADY detached at collection (initializeBlock hydrating a
193
- // fresh row inside its parse container) pass through untouched.
194
142
  if (aff.wasConnected) {
195
143
  const target =
196
144
  aff.textNode ?? aff.element ?? aff.node?.meta?.startComment;
197
145
  if (target && !target.isConnected) return;
198
146
  }
199
147
 
200
- // Use scoped state if provided (from iteration instances)
201
148
  const effectiveState = aff.scopedState || state;
202
149
 
203
- // Handle iteration updates. Same scoped-pair contract as conditionals
204
- // below: a NESTED iteration re-renders its rows against the enclosing
205
- // row's scope, so bindings reading the outer alias (`@[ability.healing]`
206
- // inside `<!-- each ability.chainDividers as d -->`) still resolve. The
207
- // overlay doubles as the nested parentScope — exactly what the initial
208
- // render passes (renderAllIterations with `{...parentScope, ...localVars}`).
209
- // Top-level iterations carry the cycle's state and no overlay, so they
210
- // keep resolving against globals.
211
150
  if (aff.type === 'iteration') {
212
151
  const iterNewState = aff.scopedState || state;
213
152
  const iterOldState = aff.oldScopedState || oldState;
@@ -221,11 +160,6 @@ export default (affected, state, manifest = {}, oldState = {}) => {
221
160
  return;
222
161
  }
223
162
 
224
- // Handle conditional updates. Use the scoped pair affected attached when
225
- // the conditional sits inside an iteration row — that pair has the row's
226
- // loop alias overlaid on current globals, so the branch eval re-runs in
227
- // the same scope the change was detected against. Top-level conditionals
228
- // pass through with the cycle's state (affected attaches it verbatim there).
229
163
  if (aff.type === 'conditional') {
230
164
  const condNewState = aff.scopedState || state;
231
165
  const condOldState = aff.oldScopedState || oldState;
@@ -233,7 +167,6 @@ export default (affected, state, manifest = {}, oldState = {}) => {
233
167
  return;
234
168
  }
235
169
 
236
- // Handle name bindings (e.g., <icon @[section.icon]>)
237
170
  if (aff.type === 'nameBinding') {
238
171
  const parkRoot = parkRootFor(aff.element);
239
172
  if (parkRoot) {
@@ -246,23 +179,9 @@ export default (affected, state, manifest = {}, oldState = {}) => {
246
179
  return;
247
180
  }
248
181
 
249
- // Handle attribute updates
250
182
  if (aff.type === 'attribute') {
251
183
  const { attrName, attrValue, element } = aff;
252
184
  try {
253
- // Reactive component src (`<component src="@[page.src]">`): resolve
254
- // the binding and (re)mount through component.js. The binding rides
255
- // along so finalize can stamp it onto the replacement wrapper
256
- // (data-vibe-src) — the tree node holding it is pruned when the
257
- // wrapper swap's removal mutation lands, and the fresh wrapper's
258
- // reparse rebuilds the knowledge from that attribute. A state change
259
- // landing inside the swap window still resolves through the
260
- // replacement chain.
261
- // Keyed component (`<component src="@[page.src]" key="@[page.path]">`):
262
- // a key change is a declared identity change — remount the mounted
263
- // component even when the src is unchanged (param→param navigation on
264
- // the same route). The first resolution just records the initial key;
265
- // the mount itself is owned by the normal component pass.
266
185
  if (attrName === 'key' && isComponentWrapper(element)) {
267
186
  const live = liveComponentWrapper(element);
268
187
  const newKey = tracked(aff, effectiveState, () =>
@@ -290,11 +209,6 @@ export default (affected, state, manifest = {}, oldState = {}) => {
290
209
  remountComponent(live, newSrc);
291
210
  if (live._vibeOutgoing) (outgoingRoots ??= []).push(live);
292
211
  } else if (live.hasAttribute('src')) {
293
- // Unresolved src mounts nothing (a no-match deep link leaves
294
- // $.page.src unset): move the binding onto data-vibe-src — the
295
- // established transport parse.js already reads — and drop the
296
- // fetchable src, so component processing and cleanup treat the
297
- // outlet as settled instead of fetching a stringified binding.
298
212
  live.setAttribute('data-vibe-src', attrValue);
299
213
  live.removeAttribute('src');
300
214
  }
@@ -315,24 +229,13 @@ export default (affected, state, manifest = {}, oldState = {}) => {
315
229
  return;
316
230
  }
317
231
 
318
- // Handle regular element updates
319
232
  const { matches, matchOuter, matchInner, input, element, textNode } = aff;
320
233
 
321
- // This prevents undefined store properties to throw an error
322
234
  try {
323
- // Evaluate the expression with state as context
324
235
  const evaluated = tracked(aff, effectiveState, () =>
325
236
  evalInScope(matchInner, effectiveState, element),
326
237
  );
327
238
 
328
- // Raw-HTML render: `$.unsafe(str)` returns a RawHtml marker. When the
329
- // binding is the sole content of its element (`<p>@[$.unsafe(x)]</p>`),
330
- // set innerHTML from the trusted string instead of escaping via
331
- // textContent. The injected subtree is marked opaque (managedNodes) so
332
- // the MutationObserver never re-walks or hydrates it — raw HTML is inert,
333
- // matching Svelte {@html}. Any non-pure use (marker spliced into
334
- // surrounding text, or an element with other children) falls through to
335
- // the normal path, where RawHtml.toString() degrades to escaped text.
336
239
  const isPureBinding = input.trim() === matchOuter;
337
240
  if (evaluated instanceof RawHtml && isPureBinding &&
338
241
  (element._vibeRawHtml || element.childNodes.length === 1)) {
@@ -340,24 +243,14 @@ export default (affected, state, manifest = {}, oldState = {}) => {
340
243
  if (element._vibeRawHtmlValue !== html) {
341
244
  element.innerHTML = html;
342
245
  for (const child of element.children) managedNodes.add(child);
246
+ markBoundValues(element, html);
343
247
  element._vibeRawHtmlValue = html;
344
248
  }
345
249
  element._vibeRawHtml = true;
346
- // The injection consumed the entry's original text node. Re-anchor
347
- // the subscriber on the ELEMENT (which survives every injection) —
348
- // otherwise the dispatch engine prunes it as dead after the first
349
- // render and the binding silently stops re-rendering.
350
250
  if (aff.binding?._sub) aff.binding._sub.anchor = element;
351
251
  return;
352
252
  }
353
253
 
354
- // Function replacer: a string replacement would run GetSubstitution on
355
- // the VALUE — `$$` collapses, `$&` re-inserts the binding text into the
356
- // DOM (which the settle gates then read as an unhydrated binding).
357
- // No trim: the text node's source whitespace is significant — a
358
- // prettier line-wrap after an inline end tag (`</strong>\n remain`)
359
- // is that word's boundary, and the compiled SPA shell stamps values
360
- // untrimmed, so trimming here glued words and diverged from the shell.
361
254
  const toReplace = input.replaceAll(matchOuter, () => evaluated);
362
255
 
363
256
  affected.forEach((innerAff) => {
@@ -366,22 +259,7 @@ export default (affected, state, manifest = {}, oldState = {}) => {
366
259
  }
367
260
  });
368
261
 
369
- // If we have a direct reference to the text node, update it specifically
370
- // This prevents wiping child elements when parent has both text and element children.
371
- // Skip the write when the value is already correct — the browser would repaint
372
- // (and any in-flight CSS transition on the row would jitter) even when no value
373
- // actually changed. Reactivity coverage is unchanged: the only state changes that
374
- // hit this path either produce a new value (still applied) or don't (now no-op).
375
- // Skip the write when the value is already correct — the browser would
376
- // repaint (and any in-flight CSS transition would jitter) even when no
377
- // value actually changed. Reactivity coverage is unchanged: state changes
378
- // that produce a new value still apply; state changes that don't are now
379
- // proper no-ops at the DOM layer.
380
262
  if (element._vibeRawHtml && !(textNode && textNode.isConnected)) {
381
- // unsafe → escaped transition: an earlier injection consumed the
382
- // entry's text node, so the element itself takes the escaped text —
383
- // and the entry ADOPTS the fresh node, so the NEXT escaped update
384
- // writes live DOM instead of the consumed original.
385
263
  element.textContent = toReplace;
386
264
  element._vibeRawHtml = false;
387
265
  element._vibeRawHtmlValue = undefined;
@@ -389,10 +267,13 @@ export default (affected, state, manifest = {}, oldState = {}) => {
389
267
  if (!element.firstChild) element.appendChild(document.createTextNode(''));
390
268
  aff.textNode = element.firstChild;
391
269
  }
270
+ if (element.firstChild) element.firstChild._vibeBoundValue = true;
392
271
  } else if (textNode && textNode.nodeType === 3) {
393
272
  if (textNode.textContent !== toReplace) textNode.textContent = toReplace;
394
- } else if (element.textContent !== toReplace) {
395
- element.textContent = toReplace;
273
+ textNode._vibeBoundValue = true;
274
+ } else {
275
+ if (element.textContent !== toReplace) element.textContent = toReplace;
276
+ if (element.firstChild?.nodeType === 3) element.firstChild._vibeBoundValue = true;
396
277
  }
397
278
  } catch (e) {
398
279
  reportEvalError(matchInner ?? input, element, e);