@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/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)',
|
|
31
|
-
Manifested: 'oklch(0.55 0.02 250)',
|
|
32
|
-
Observer: 'oklch(0.60 0.11 240)',
|
|
33
|
-
Cleanup: 'oklch(0.55 0.02 250)',
|
|
34
|
-
Parsed: 'oklch(0.62 0.12 50)',
|
|
35
|
-
Hydrated: 'oklch(0.60 0.12 340)',
|
|
36
|
-
Iterated: 'oklch(0.58 0.11 142)',
|
|
37
|
-
Evaluated: 'oklch(0.58 0.11 142)',
|
|
38
|
-
Fetched: 'oklch(0.59 0.12 307)',
|
|
39
|
-
'Fet(ca)ched': 'oklch(0.59 0.12 307)',
|
|
40
|
-
Mutation: 'oklch(0.60 0.11 240)',
|
|
41
|
-
Proxy: 'oklch(0.60 0.11 240)',
|
|
42
|
-
Hyperspeed: 'oklch(0.60 0.11 180)',
|
|
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)',
|
|
48
|
-
red: 'oklch(0.637 0.237 27.33)',
|
|
49
|
-
yellow: 'oklch(0.809 0.177 94.36)',
|
|
50
|
-
pink: 'oklch(0.649 0.237 346.06)',
|
|
51
|
-
slate: 'oklch(0.556 0.016 256.85)',
|
|
52
|
-
commentGreen: 'oklch(0.58 0.11 142)',
|
|
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, ' ');
|
|
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)',
|
|
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)',
|
|
87
|
+
'color: oklch(0.70 0.01 250)',
|
|
113
88
|
'color: inherit',
|
|
114
89
|
);
|
|
115
90
|
}
|
package/runtime/dispatch.js
CHANGED
|
@@ -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();
|
package/runtime/hydrate.js
CHANGED
|
@@ -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;
|
|
29
|
-
sub.lastScope = effectiveState;
|
|
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
|
-
|
|
395
|
-
|
|
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);
|