@ape-egg/vibe 2.3.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +14 -4
  2. package/boot.js +4 -4
  3. package/component.js +27 -29
  4. package/hot-module-refresh.js +4 -4
  5. package/index.js +10 -15
  6. package/llms.txt +8 -6
  7. package/package.json +19 -14
  8. package/runtime/affected.js +159 -36
  9. package/runtime/cleanup.js +45 -1
  10. package/runtime/component.js +312 -99
  11. package/runtime/conditionals.js +111 -14
  12. package/runtime/debug.js +24 -0
  13. package/runtime/dispatch.js +172 -0
  14. package/runtime/hydrate.js +251 -111
  15. package/runtime/index.js +180 -71
  16. package/runtime/iterate.js +125 -50
  17. package/runtime/iteration-utils.js +59 -8
  18. package/runtime/manifest.js +77 -2
  19. package/runtime/parse.js +69 -5
  20. package/runtime/pre-compiled-iterations.js +19 -6
  21. package/runtime/pre-compiled-manifest.js +13 -4
  22. package/runtime/staging.js +153 -0
  23. package/runtime/state.js +31 -0
  24. package/runtime/tracking.js +173 -0
  25. package/runtime/utils.js +155 -78
  26. package/spa.js +77 -14
  27. package/vibe.css +8 -4
  28. package/CHANGELOG.md +0 -1196
  29. package/ROADMAP.md +0 -397
  30. package/compiler/bin/vibe-compile.js +0 -121
  31. package/compiler/native/.gitkeep +0 -0
  32. package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
  33. package/compiler/native/vibe-compiler-linux-x64 +0 -0
  34. package/compiler/src/Cargo.lock +0 -2023
  35. package/compiler/src/Cargo.toml +0 -38
  36. package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
  37. package/compiler/src/compiler/binding_case.rs +0 -88
  38. package/compiler/src/compiler/compile.rs +0 -2880
  39. package/compiler/src/compiler/component_tagger.rs +0 -469
  40. package/compiler/src/compiler/iteration_optimizer.rs +0 -455
  41. package/compiler/src/compiler/js_analyzer.rs +0 -715
  42. package/compiler/src/compiler/manifest_builder.rs +0 -693
  43. package/compiler/src/compiler/mod.rs +0 -16
  44. package/compiler/src/compiler/name_binding_protect.rs +0 -207
  45. package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
  46. package/compiler/src/compiler/spa.rs +0 -477
  47. package/compiler/src/compiler/state_extractor.rs +0 -263
  48. package/compiler/src/compiler/value_stamper.rs +0 -921
  49. package/compiler/src/compiler/watcher.rs +0 -1278
  50. package/compiler/src/config.rs +0 -279
  51. package/compiler/src/main.rs +0 -358
  52. package/compiler/src/parser/element.rs +0 -96
  53. package/compiler/src/parser/html.rs +0 -1004
  54. package/compiler/src/parser/mod.rs +0 -8
  55. package/runtime/pre-compiled-manifest.test.mjs +0 -58
  56. package/runtime/scope.js +0 -50
  57. package/test-results/.last-run.json +0 -4
@@ -1,12 +1,201 @@
1
1
  import { updateIteration } from './iterate.js';
2
2
  import { updateConditional, managedNodes } from './conditionals.js';
3
- import { isComponentWrapper, liveComponentWrapper, remountComponent, forceRemount } from './component.js';
3
+ import { liveComponentWrapper, remountComponent, forceRemount } from './component.js';
4
+ import { isComponentWrapper, parkRootFor, parkBinding } from './staging.js';
4
5
  import { VALUE_ATTRS, DOM_PROPERTIES, BINDING_REGEX, PURE_BINDING_REGEX } from './constants.js';
5
- import { evalInScope, resolveCaseInsensitivePath } from './utils.js';
6
+ import { evalInScope, resolveCaseInsensitivePath, overlayKeysOf } from './utils.js';
6
7
  import { RawHtml } from './raw-html.js';
8
+ import { beginTracking, endTracking } from './tracking.js';
9
+ import { reportEvalError } from './debug.js';
10
+
11
+ // Run one entry's evaluation inside a tracking window. The subscriber is
12
+ // hosted on the walk's cached binding object (aff.binding) — stable per
13
+ // binding across flushes, invalidated exactly when the binding cache
14
+ // rebuilds. Entries without a binding host (hand-built lists from external
15
+ // callers) evaluate untracked, exactly as before.
16
+ const tracked = (aff, effectiveState, fn) => {
17
+ const m = aff.binding;
18
+ if (!m) return fn();
19
+ const sub = (m._sub ??= {
20
+ kind: aff.type ?? 'text',
21
+ anchor: aff.textNode ?? aff.element,
22
+ });
23
+ sub.entry = aff; // latest entry shape — dispatch rebuilds scope at fire time
24
+ sub.lastScope = effectiveState; // overlay source for dispatch-time rescoping
25
+ beginTracking(sub, overlayKeysOf(effectiveState));
26
+ try {
27
+ return fn();
28
+ } finally {
29
+ endTracking();
30
+ }
31
+ };
32
+
33
+ const applyNameBinding = (aff, effectiveState) => {
34
+ const { nameBinding, matchInner, element } = aff;
35
+ try {
36
+ // HTML lowercases attribute names, so we need case-insensitive lookup.
37
+ // Try exact match first, then walk the path case-insensitively if the
38
+ // exact lookup returned nothing — this recovers camelCase property
39
+ // names in dotted paths like `<icon @[fx.convertsIcon]>` (arrives at
40
+ // runtime as `@[fx.convertsicon]`).
41
+ let attrName = evalInScope(matchInner, effectiveState, element);
42
+
43
+ if (!attrName) {
44
+ attrName = resolveCaseInsensitivePath(effectiveState, matchInner);
45
+ }
46
+
47
+ // Track multiple name bindings per element (need a map of binding -> evaluated attr)
48
+ if (!element._vibeNameBindings) {
49
+ element._vibeNameBindings = new Map();
50
+ }
51
+
52
+ // Remove the old evaluated attribute for this specific binding
53
+ const oldAttrName = element._vibeNameBindings.get(nameBinding);
54
+ if (oldAttrName) {
55
+ element.removeAttribute(oldAttrName);
56
+ }
57
+
58
+ // Remove the binding attribute itself
59
+ if (element.hasAttribute(nameBinding)) {
60
+ element.removeAttribute(nameBinding);
61
+ }
62
+
63
+ // Set the new attribute (empty value for boolean-like attributes)
64
+ if (attrName) {
65
+ element.setAttribute(attrName, '');
66
+ element._vibeNameBindings.set(nameBinding, attrName);
67
+ } else {
68
+ element._vibeNameBindings.delete(nameBinding);
69
+ }
70
+
71
+ // A binding relocated into `data-vibe-namebind` (clone path) has served its
72
+ // transport purpose once the real attribute is set — drop it so the rendered
73
+ // DOM matches the batch path, which never emits it.
74
+ if (element.hasAttribute('data-vibe-namebind')) {
75
+ element.removeAttribute('data-vibe-namebind');
76
+ }
77
+ } catch (e) {
78
+ console.error('Error hydrating name binding:', e);
79
+ }
80
+ };
81
+
82
+ const applyAttributeBinding = (aff, effectiveState) => {
83
+ const { attrName, attrValue, element } = aff;
84
+ try {
85
+ // Check if this is a pure binding (e.g., value="@[inputValue]")
86
+ const isPureBinding = attrValue.match(PURE_BINDING_REGEX);
87
+ const isDomProperty = DOM_PROPERTIES.includes(attrName);
88
+ // Value attrs keep their string value; everything else is boolean-like (removed when falsy)
89
+ const isValueAttr =
90
+ VALUE_ATTRS.includes(attrName) ||
91
+ attrName.startsWith('data-') ||
92
+ attrName.startsWith('aria-') ||
93
+ attrName.startsWith('on');
94
+
95
+ if (isDomProperty && isPureBinding) {
96
+ // For DOM properties (value, checked, selected), set BOTH property AND attribute
97
+ // Property: Fast runtime updates, what the user sees
98
+ // Attribute: Truthful DOM representation, enables compiler snapshots via outerHTML
99
+ const expr = isPureBinding[1];
100
+ const value = evalInScope(expr, effectiveState, element);
101
+ if (element[attrName] !== value) element[attrName] = value;
102
+ if (attrName === 'value') {
103
+ if (value !== undefined && value !== null) {
104
+ const str = String(value);
105
+ if (element.getAttribute(attrName) !== str) element.setAttribute(attrName, str);
106
+ }
107
+ } else if (value) {
108
+ // checked/selected are boolean — the truthful attribute form is
109
+ // presence (empty) when truthy, absence when falsy. Stringifying
110
+ // would leave checked="false", which is "checked" to CSS and HTML.
111
+ if (element.getAttribute(attrName) !== '') element.setAttribute(attrName, '');
112
+ } else if (element.hasAttribute(attrName)) {
113
+ element.removeAttribute(attrName);
114
+ }
115
+ } else if (!isValueAttr && isPureBinding) {
116
+ // Boolean-like attributes: add or remove based on truthiness.
117
+ // Compare both presence AND value — initial hydration starts with
118
+ // the raw `@[...]` binding text as the attribute value, so
119
+ // `hasAttribute` alone isn't enough to know the canonical state is
120
+ // already set.
121
+ const expr = isPureBinding[1];
122
+ const value = evalInScope(expr, effectiveState, element);
123
+ if (value) {
124
+ if (element.getAttribute(attrName) !== '') {
125
+ element.setAttribute(attrName, '');
126
+ }
127
+ } else if (element.hasAttribute(attrName)) {
128
+ element.removeAttribute(attrName);
129
+ }
130
+ } else {
131
+ // Value attribute - replace bindings with values
132
+ const newValue = attrValue.replace(BINDING_REGEX, (_, expr) => {
133
+ return evalInScope(expr, effectiveState, element);
134
+ });
135
+ if (element.getAttribute(attrName) !== newValue) {
136
+ element.setAttribute(attrName, newValue);
137
+ }
138
+ }
139
+ } catch (e) {
140
+ reportEvalError(attrValue, element, e);
141
+ }
142
+ };
7
143
 
8
144
  export default (affected, state, manifest = {}, oldState = {}) => {
9
- affected.forEach((aff) => {
145
+ // Wrappers that entered a reactive-src remount DURING this pass (the src/key
146
+ // handlers below). affected() collected this list before the outgoing flag
147
+ // existed, so the outgoing subtree's entries are still in it — and this is
148
+ // exactly the flush where route state flips. Applying them would paint the
149
+ // outgoing page against the incoming route for the whole fetch+mount window
150
+ // (the mid-navigation CSS collapse). Tree-walk order guarantees the
151
+ // wrapper's own src/key entries precede its subtree's entries, so skipping
152
+ // by ancestry here is airtight. Null until a remount actually happens —
153
+ // zero cost on ordinary flushes.
154
+ let outgoingRoots = null;
155
+ const underOutgoingRoot = (aff) => {
156
+ if (!outgoingRoots) return false;
157
+ const el = aff.element ?? aff.node?.meta?.startComment?.parentElement;
158
+ if (!el) return false;
159
+ for (let i = 0; i < outgoingRoots.length; i++) {
160
+ const root = outgoingRoots[i];
161
+ if (root !== el && root.contains(el)) return true;
162
+ }
163
+ return false;
164
+ };
165
+
166
+ // Two passes: wrapper src/key entries (remount triggers) first, everything
167
+ // else second. A remount marks its wrapper outgoing and starts staging —
168
+ // the freeze skip below and the ancestor-binding parking both depend on
169
+ // that registration, and tree-walk order would otherwise process an
170
+ // ancestor's bindings (the `<page @[page.name]>` styling context) before
171
+ // the outlet entry that begins the staging they must park against.
172
+ const remountTriggers = [];
173
+ const ordinary = [];
174
+ for (const aff of affected) {
175
+ const isRemountTrigger =
176
+ aff.type === 'attribute' &&
177
+ (aff.attrName === 'src' || aff.attrName === 'key') &&
178
+ isComponentWrapper(aff.element);
179
+ (isRemountTrigger ? remountTriggers : ordinary).push(aff);
180
+ }
181
+
182
+ const processEntry = (aff) => {
183
+ if (underOutgoingRoot(aff)) return;
184
+
185
+ // Torn down mid-pass: the entry's target was connected when the walk
186
+ // collected it but left the document before this entry processed — an
187
+ // earlier entry's teardown (an iteration emptying, a branch flip)
188
+ // removed it. Same ordering hole as the outgoing-remount skip above.
189
+ // Processing it would write to dead DOM and re-register a subscriber
190
+ // the teardown just pruned, pinning the removed subtree. Entries that
191
+ // were ALREADY detached at collection (initializeBlock hydrating a
192
+ // fresh row inside its parse container) pass through untouched.
193
+ if (aff.wasConnected) {
194
+ const target =
195
+ aff.textNode ?? aff.element ?? aff.node?.meta?.startComment;
196
+ if (target && !target.isConnected) return;
197
+ }
198
+
10
199
  // Use scoped state if provided (from iteration instances)
11
200
  const effectiveState = aff.scopedState || state;
12
201
 
@@ -30,52 +219,14 @@ export default (affected, state, manifest = {}, oldState = {}) => {
30
219
 
31
220
  // Handle name bindings (e.g., <icon @[section.icon]>)
32
221
  if (aff.type === 'nameBinding') {
33
- const { nameBinding, matchInner, element } = aff;
34
- try {
35
- // HTML lowercases attribute names, so we need case-insensitive lookup.
36
- // Try exact match first, then walk the path case-insensitively if the
37
- // exact lookup returned nothing — this recovers camelCase property
38
- // names in dotted paths like `<icon @[fx.convertsIcon]>` (arrives at
39
- // runtime as `@[fx.convertsicon]`).
40
- let attrName = evalInScope(matchInner, effectiveState, element);
41
-
42
- if (!attrName) {
43
- attrName = resolveCaseInsensitivePath(effectiveState, matchInner);
44
- }
45
-
46
- // Track multiple name bindings per element (need a map of binding -> evaluated attr)
47
- if (!element._vibeNameBindings) {
48
- element._vibeNameBindings = new Map();
49
- }
50
-
51
- // Remove the old evaluated attribute for this specific binding
52
- const oldAttrName = element._vibeNameBindings.get(nameBinding);
53
- if (oldAttrName) {
54
- element.removeAttribute(oldAttrName);
55
- }
56
-
57
- // Remove the binding attribute itself
58
- if (element.hasAttribute(nameBinding)) {
59
- element.removeAttribute(nameBinding);
60
- }
61
-
62
- // Set the new attribute (empty value for boolean-like attributes)
63
- if (attrName) {
64
- element.setAttribute(attrName, '');
65
- element._vibeNameBindings.set(nameBinding, attrName);
66
- } else {
67
- element._vibeNameBindings.delete(nameBinding);
68
- }
69
-
70
- // A binding relocated into `data-vibe-namebind` (clone path) has served its
71
- // transport purpose once the real attribute is set — drop it so the rendered
72
- // DOM matches the batch path, which never emits it.
73
- if (element.hasAttribute('data-vibe-namebind')) {
74
- element.removeAttribute('data-vibe-namebind');
75
- }
76
- } catch (e) {
77
- console.error('Error hydrating name binding:', e);
222
+ const parkRoot = parkRootFor(aff.element);
223
+ if (parkRoot) {
224
+ parkBinding(parkRoot, aff.element, 'nb:' + aff.nameBinding, () =>
225
+ applyNameBinding(aff, effectiveState),
226
+ );
227
+ return;
78
228
  }
229
+ tracked(aff, effectiveState, () => applyNameBinding(aff, effectiveState));
79
230
  return;
80
231
  }
81
232
 
@@ -98,24 +249,30 @@ export default (affected, state, manifest = {}, oldState = {}) => {
98
249
  // the mount itself is owned by the normal component pass.
99
250
  if (attrName === 'key' && isComponentWrapper(element)) {
100
251
  const live = liveComponentWrapper(element);
101
- const newKey = attrValue.replace(BINDING_REGEX, (_, expr) =>
102
- evalInScope(expr, effectiveState, live) ?? '',
252
+ const newKey = tracked(aff, effectiveState, () =>
253
+ attrValue.replace(BINDING_REGEX, (_, expr) =>
254
+ evalInScope(expr, effectiveState, live) ?? '',
255
+ ),
103
256
  );
104
257
  live._vibeKeyBinding = attrValue;
105
258
  const prevKey = live._vibeMountedKey;
106
259
  live._vibeMountedKey = newKey;
107
260
  if (prevKey !== undefined && newKey !== prevKey) forceRemount(live);
261
+ if (live._vibeOutgoing) (outgoingRoots ??= []).push(live);
108
262
  return;
109
263
  }
110
264
 
111
265
  if (attrName === 'src' && isComponentWrapper(element)) {
112
266
  const live = liveComponentWrapper(element);
113
- const newSrc = attrValue.replace(BINDING_REGEX, (_, expr) =>
114
- evalInScope(expr, effectiveState, live) ?? '',
267
+ const newSrc = tracked(aff, effectiveState, () =>
268
+ attrValue.replace(BINDING_REGEX, (_, expr) =>
269
+ evalInScope(expr, effectiveState, live) ?? '',
270
+ ),
115
271
  );
116
272
  live._vibeSrcBinding = attrValue;
117
273
  if (newSrc) {
118
274
  remountComponent(live, newSrc);
275
+ if (live._vibeOutgoing) (outgoingRoots ??= []).push(live);
119
276
  } else if (live.hasAttribute('src')) {
120
277
  // Unresolved src mounts nothing (a no-match deep link leaves
121
278
  // $.page.src unset): move the binding onto data-vibe-src — the
@@ -128,61 +285,17 @@ export default (affected, state, manifest = {}, oldState = {}) => {
128
285
  return;
129
286
  }
130
287
 
131
- // Check if this is a pure binding (e.g., value="@[inputValue]")
132
- const isPureBinding = attrValue.match(PURE_BINDING_REGEX);
133
- const isDomProperty = DOM_PROPERTIES.includes(attrName);
134
- // Value attrs keep their string value; everything else is boolean-like (removed when falsy)
135
- const isValueAttr =
136
- VALUE_ATTRS.includes(attrName) ||
137
- attrName.startsWith('data-') ||
138
- attrName.startsWith('aria-') ||
139
- attrName.startsWith('on');
140
-
141
- if (isDomProperty && isPureBinding) {
142
- // For DOM properties (value, checked, selected), set BOTH property AND attribute
143
- // Property: Fast runtime updates, what the user sees
144
- // Attribute: Truthful DOM representation, enables compiler snapshots via outerHTML
145
- const expr = isPureBinding[1];
146
- const value = evalInScope(expr, effectiveState, element);
147
- if (element[attrName] !== value) element[attrName] = value;
148
- if (attrName === 'value') {
149
- if (value !== undefined && value !== null) {
150
- const str = String(value);
151
- if (element.getAttribute(attrName) !== str) element.setAttribute(attrName, str);
152
- }
153
- } else if (value) {
154
- // checked/selected are boolean — the truthful attribute form is
155
- // presence (empty) when truthy, absence when falsy. Stringifying
156
- // would leave checked="false", which is "checked" to CSS and HTML.
157
- if (element.getAttribute(attrName) !== '') element.setAttribute(attrName, '');
158
- } else if (element.hasAttribute(attrName)) {
159
- element.removeAttribute(attrName);
160
- }
161
- } else if (!isValueAttr && isPureBinding) {
162
- // Boolean-like attributes: add or remove based on truthiness.
163
- // Compare both presence AND value — initial hydration starts with
164
- // the raw `@[...]` binding text as the attribute value, so
165
- // `hasAttribute` alone isn't enough to know the canonical state is
166
- // already set.
167
- const expr = isPureBinding[1];
168
- const value = evalInScope(expr, effectiveState, element);
169
- if (value) {
170
- if (element.getAttribute(attrName) !== '') {
171
- element.setAttribute(attrName, '');
172
- }
173
- } else if (element.hasAttribute(attrName)) {
174
- element.removeAttribute(attrName);
175
- }
176
- } else {
177
- // Value attribute - replace bindings with values
178
- const newValue = attrValue.replace(BINDING_REGEX, (_, expr) => {
179
- return evalInScope(expr, effectiveState, element);
180
- });
181
- if (element.getAttribute(attrName) !== newValue) {
182
- element.setAttribute(attrName, newValue);
183
- }
288
+ const parkRoot = parkRootFor(element);
289
+ if (parkRoot) {
290
+ parkBinding(parkRoot, element, 'attr:' + attrName, () =>
291
+ applyAttributeBinding(aff, effectiveState),
292
+ );
293
+ return;
184
294
  }
185
- } catch (e) {}
295
+ tracked(aff, effectiveState, () => applyAttributeBinding(aff, effectiveState));
296
+ } catch (e) {
297
+ reportEvalError(aff.attrValue ?? aff.matchInner, aff.element, e);
298
+ }
186
299
  return;
187
300
  }
188
301
 
@@ -192,7 +305,9 @@ export default (affected, state, manifest = {}, oldState = {}) => {
192
305
  // This prevents undefined store properties to throw an error
193
306
  try {
194
307
  // Evaluate the expression with state as context
195
- const evaluated = evalInScope(matchInner, effectiveState, element);
308
+ const evaluated = tracked(aff, effectiveState, () =>
309
+ evalInScope(matchInner, effectiveState, element),
310
+ );
196
311
 
197
312
  // Raw-HTML render: `$.unsafe(str)` returns a RawHtml marker. When the
198
313
  // binding is the sole content of its element (`<p>@[$.unsafe(x)]</p>`),
@@ -212,10 +327,18 @@ export default (affected, state, manifest = {}, oldState = {}) => {
212
327
  element._vibeRawHtmlValue = html;
213
328
  }
214
329
  element._vibeRawHtml = true;
330
+ // The injection consumed the entry's original text node. Re-anchor
331
+ // the subscriber on the ELEMENT (which survives every injection) —
332
+ // otherwise the dispatch engine prunes it as dead after the first
333
+ // render and the binding silently stops re-rendering.
334
+ if (aff.binding?._sub) aff.binding._sub.anchor = element;
215
335
  return;
216
336
  }
217
337
 
218
- const toReplace = input.replaceAll(matchOuter, evaluated).trim();
338
+ // Function replacer: a string replacement would run GetSubstitution on
339
+ // the VALUE — `$$` collapses, `$&` re-inserts the binding text into the
340
+ // DOM (which the settle gates then read as an unhydrated binding).
341
+ const toReplace = input.replaceAll(matchOuter, () => evaluated).trim();
219
342
 
220
343
  affected.forEach((innerAff) => {
221
344
  if (innerAff.element === element) {
@@ -234,11 +357,28 @@ export default (affected, state, manifest = {}, oldState = {}) => {
234
357
  // value actually changed. Reactivity coverage is unchanged: state changes
235
358
  // that produce a new value still apply; state changes that don't are now
236
359
  // proper no-ops at the DOM layer.
237
- if (textNode && textNode.nodeType === 3) {
360
+ if (element._vibeRawHtml && !(textNode && textNode.isConnected)) {
361
+ // unsafe → escaped transition: an earlier injection consumed the
362
+ // entry's text node, so the element itself takes the escaped text —
363
+ // and the entry ADOPTS the fresh node, so the NEXT escaped update
364
+ // writes live DOM instead of the consumed original.
365
+ element.textContent = toReplace;
366
+ element._vibeRawHtml = false;
367
+ element._vibeRawHtmlValue = undefined;
368
+ if (textNode) {
369
+ if (!element.firstChild) element.appendChild(document.createTextNode(''));
370
+ aff.textNode = element.firstChild;
371
+ }
372
+ } else if (textNode && textNode.nodeType === 3) {
238
373
  if (textNode.textContent !== toReplace) textNode.textContent = toReplace;
239
374
  } else if (element.textContent !== toReplace) {
240
375
  element.textContent = toReplace;
241
376
  }
242
- } catch (e) {}
243
- });
377
+ } catch (e) {
378
+ reportEvalError(matchInner ?? input, element, e);
379
+ }
380
+ };
381
+
382
+ remountTriggers.forEach(processEntry);
383
+ ordinary.forEach(processEntry);
244
384
  };