@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,9 +1,13 @@
1
1
  import parse from './parse.js';
2
- import affected from './affected.js';
2
+ import affected, { nodeSubscriberOf } from './affected.js';
3
3
  import hydrate from './hydrate.js';
4
+ import { beginTracking, endTracking, pruneDisconnected } from './tracking.js';
5
+ import { overlayKeysOf } from './utils.js';
6
+ import { setManifestEntry, manifestPathOf } from './manifest.js';
4
7
  import { createScopedState, renderAllIterations, initializeBlock, resolveIterationComponentProps } from './iterate.js';
5
8
  import { evalInScope } from './utils.js';
6
9
  import { collectComponentIds, releaseOrphanedComponentState, executeCompiledComponentScriptsIn } from './component.js';
10
+ import { isOutgoing } from './staging.js';
7
11
 
8
12
  // Registry of DOM nodes owned by conditional branches.
9
13
  // Maps a DOM node to { nodes: array_ref, index: number } so that
@@ -17,22 +21,19 @@ export const branchNodeRegistry = new WeakMap();
17
21
  export const managedNodes = new WeakSet();
18
22
 
19
23
  // Find the dot path of a conditional node in the manifest.
20
- // Searches for the parent element, then appends the conditional's key.
24
+ // Resolves the parent element through the reverse index, then appends the
25
+ // conditional's key.
21
26
  const findConditionalPath = (node, manifest) => {
22
- const parent = node.meta.startComment.parentNode;
23
- const parentEntry = Object.entries(manifest).find(([_, el]) => el === parent);
24
- if (!parentEntry) return null;
27
+ const parentPath = manifestPathOf(manifest, node.meta.startComment.parentNode);
28
+ if (parentPath === null) return null;
25
29
 
26
- // Find which key this conditional has in the parent's children
27
- // by matching the startComment reference
28
- const [parentPath] = parentEntry;
29
30
  return `${parentPath}.${node._key}`;
30
31
  };
31
32
 
32
33
  // Register branch tree nodes in the manifest (recursive)
33
34
  const addBranchToManifest = (tree, manifest, basePath) => {
34
35
  if (tree.element) {
35
- manifest[basePath] = tree.element;
36
+ setManifestEntry(manifest, basePath, tree.element);
36
37
  }
37
38
  if (tree.children) {
38
39
  for (const key in tree.children) {
@@ -71,6 +72,7 @@ export const extractDependencies = (expression) => {
71
72
  return [...new Set(matches)]; // Unique values
72
73
  };
73
74
 
75
+
74
76
  // Render all conditionals in the parsed tree
75
77
  export const renderAllConditionals = (tree, state, manifest, parentScope = {}) => {
76
78
  let count = 0;
@@ -81,8 +83,9 @@ export const renderAllConditionals = (tree, state, manifest, parentScope = {}) =
81
83
  return 1;
82
84
  }
83
85
 
84
- // Recursively render conditionals in child nodes
85
- if (tree.children) {
86
+ // Recursively render conditionals in child nodes — except under an
87
+ // outgoing wrapper, whose content the pending swap will replace wholesale.
88
+ if (tree.children && !(tree.element && isOutgoing(tree.element))) {
86
89
  Object.keys(tree.children).forEach((key) => {
87
90
  const child = tree.children[key];
88
91
  if (typeof child === 'object' && child !== null) {
@@ -94,6 +97,42 @@ export const renderAllConditionals = (tree, state, manifest, parentScope = {}) =
94
97
  return count;
95
98
  };
96
99
 
100
+ // Re-settle every ALREADY-RENDERED conditional against the current world.
101
+ // The boot's initial pass can run before module scripts have evaluated
102
+ // (compiled shell: boot.js rides the vibe-module chain), so a gate on a
103
+ // module-provided global — `<!-- if window.isDev -->` — first evaluates
104
+ // against the pre-module world and carries no reactive dependency that would
105
+ // ever re-check it. Walks like renderAllConditionals but routes through
106
+ // updateConditional: value-unchanged branches are a no-op, changed ones
107
+ // mount/unmount through the normal branch machinery.
108
+ export const settleConditionals = (tree, state, manifest, parentScope = {}) => {
109
+ if (tree.type === 'conditional') {
110
+ updateConditional(tree, state, state, manifest, parentScope);
111
+ // A COMPILED pre-rendered conditional keeps its live content in
112
+ // `children` (never mountBranch'd) — nested conditionals there need
113
+ // settling too. Connected-root check skips a runtime conditional's
114
+ // detached template nodes.
115
+ if (tree.children) {
116
+ Object.keys(tree.children).forEach((key) => {
117
+ const child = tree.children[key];
118
+ if (!child || typeof child !== 'object') return;
119
+ const root = child.element ?? child.textNode;
120
+ if (!root || !root.isConnected) return;
121
+ settleConditionals(child, state, manifest, parentScope);
122
+ });
123
+ }
124
+ return;
125
+ }
126
+ if (tree.children && !(tree.element && isOutgoing(tree.element))) {
127
+ Object.keys(tree.children).forEach((key) => {
128
+ const child = tree.children[key];
129
+ if (typeof child === 'object' && child !== null) {
130
+ settleConditionals(child, state, manifest, parentScope);
131
+ }
132
+ });
133
+ }
134
+ };
135
+
97
136
  // Initial render of a conditional block
98
137
  export const renderConditional = (node, state, manifest, parentScope = {}) => {
99
138
  const { expression, startComment, endComment, branches } = node.meta;
@@ -141,8 +180,13 @@ export const renderConditional = (node, state, manifest, parentScope = {}) => {
141
180
  // @ts-ignore - adding custom property to comment node
142
181
  startComment.__vibeRendered = true;
143
182
 
144
- // Evaluate condition with current state
183
+ // Evaluate condition with current state — inside a tracking window, so the
184
+ // conditional subscribes to what its expression read from first render on.
185
+ const trackSub = nodeSubscriberOf(node, 'conditional');
186
+ trackSub.lastScope = state;
187
+ beginTracking(trackSub, overlayKeysOf(state));
145
188
  const conditionResult = evaluateCondition(expression, state, startComment.parentElement);
189
+ endTracking();
146
190
 
147
191
  // Determine which branch to mount
148
192
  const branchToMount = conditionResult ? branches.if : branches.else;
@@ -243,7 +287,7 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
243
287
  // mounts (or re-mounts after unmount) must run its own scripts here to
244
288
  // re-register component-local state. Must happen BEFORE rendering nested
245
289
  // iterations/conditionals so `<!-- each _cN.x -->` sees the registered state.
246
- executeCompiledComponentScriptsIn(clonedNodes);
290
+ const scriptsPending = executeCompiledComponentScriptsIn(clonedNodes);
247
291
 
248
292
  // Recursively render any nested iterations and conditionals
249
293
  if (branchTree) {
@@ -257,6 +301,31 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
257
301
  nodes: clonedNodes,
258
302
  parsedTree: branchTree,
259
303
  };
304
+
305
+ // Import-bearing branch scripts run async through the script chain, so the
306
+ // eager render above may evaluate expressions before the window helpers those
307
+ // scripts define exist — and defining a global is not a state write, so no
308
+ // flush ever re-dispatches them (the fetch path never has this gap: it mounts
309
+ // DOM only after its scripts ran). Finish the mount when the chain settles:
310
+ // re-render this branch's directives against the fully-scripted world.
311
+ // Idempotent — rendered iterations early-return, value-unchanged conditionals
312
+ // are a no-op — and skipped when a remount superseded this instance.
313
+ //
314
+ // The settle evaluates CURRENT state (the live `$`), never the mount-time
315
+ // snapshot: state that legitimately changed while the imports were pending
316
+ // (a nested gate flipping true off a socket flush) already dispatched and
317
+ // mounted — settling against the stale snapshot would unmount it again.
318
+ // Loop-scoped branches keep their mount scope (the row's aliases aren't
319
+ // reachable from the root proxy); a row update supersedes activeInstance
320
+ // and the guard skips the settle entirely.
321
+ if (scriptsPending && branchTree) {
322
+ scriptsPending.then(() => {
323
+ if (node.runtime.activeInstance?.nodes !== clonedNodes) return;
324
+ const healState = node.meta.scopeAliases?.length ? scopedState : (window.$ ?? scopedState);
325
+ renderAllIterations(branchTree, healState, manifest, parentScope);
326
+ settleConditionals(branchTree, healState, manifest, parentScope);
327
+ });
328
+ }
260
329
  };
261
330
 
262
331
  // Unmount currently active branch
@@ -299,11 +368,34 @@ const unmountBranch = (node, manifest) => {
299
368
 
300
369
  // CLEANUP OF CURRENT STATE
301
370
  releaseOrphanedComponentState(ids);
371
+ // The swept range's binding subscribers are dead — their anchors just left
372
+ // the document. (The conditional's own subscriber survives: its anchor is
373
+ // the start comment, which stays.)
374
+ pruneDisconnected();
302
375
 
303
376
  // Clear active instance
304
377
  node.runtime.activeInstance = null;
305
378
  };
306
379
 
380
+ // Subscription dispatch: flip the branch if the expression's value moved,
381
+ // nothing else. Branch CONTENT updates arrive through the content's own
382
+ // subscribers — re-walking the branch here (updateConditional's else path)
383
+ // would reintroduce a partial walk per dirty conditional. A mounting branch
384
+ // registers its fresh subscribers through initializeBlock's hydrate pass.
385
+ export const dispatchConditional = (node, newState, manifest, parentScope = {}) => {
386
+ if (!node.runtime.templateRemoved) return;
387
+ const sub = nodeSubscriberOf(node, 'conditional');
388
+ sub.lastScope = newState;
389
+ beginTracking(sub, overlayKeysOf(newState));
390
+ const result = evaluateCondition(node.meta.expression, newState, node.meta.startComment?.parentElement);
391
+ endTracking();
392
+ const newBranchData = result ? node.meta.branches.if : node.meta.branches.else;
393
+ if (node.runtime.activeBranch !== newBranchData) {
394
+ mountBranch(node, newBranchData, newState, manifest, parentScope);
395
+ node.runtime.activeBranch = newBranchData;
396
+ }
397
+ };
398
+
307
399
  // Update conditional when dependencies change
308
400
  export const updateConditional = (node, newState, oldState, manifest, parentScope = {}) => {
309
401
  const { expression, branches, startComment } = node.meta;
@@ -313,8 +405,13 @@ export const updateConditional = (node, newState, oldState, manifest, parentScop
313
405
  return;
314
406
  }
315
407
 
316
- // Evaluate expression with new state
408
+ // Evaluate expression with new state — re-records the subscription every
409
+ // update, so branch-dependent reads self-heal.
410
+ const trackSub = nodeSubscriberOf(node, 'conditional');
411
+ trackSub.lastScope = newState;
412
+ beginTracking(trackSub, overlayKeysOf(newState));
317
413
  const newConditionResult = evaluateCondition(expression, newState, startComment?.parentElement);
414
+ endTracking();
318
415
  const newBranchData = newConditionResult ? branches.if : branches.else;
319
416
 
320
417
  // Check if branch changed (compare references)
package/runtime/debug.js CHANGED
@@ -1,5 +1,29 @@
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
+ let speculativeDepth = 0;
13
+ export const beginSpeculative = () => {
14
+ speculativeDepth++;
15
+ };
16
+ export const endSpeculative = () => {
17
+ speculativeDepth--;
18
+ };
19
+
20
+ const reportedEvalErrors = new Set();
21
+ export const reportEvalError = (expression, element, error) => {
22
+ if (!globalThis.__vibe?.debug || speculativeDepth > 0 || reportedEvalErrors.has(expression)) return;
23
+ reportedEvalErrors.add(expression);
24
+ console.error(`[vibe] Binding evaluation failed: @[${expression}]`, error, element ?? '');
25
+ };
26
+
3
27
  // Phase colors - synced with index.css debug phase colors
4
28
  // Muted palette based on comment green (#6a9955)
5
29
  const PHASE_COLORS = {
@@ -0,0 +1,172 @@
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
+ import hydrate from './hydrate.js';
11
+ import { updateIteration } from './iterate.js';
12
+ import { dispatchConditional } from './conditionals.js';
13
+ import { beginTracking, endTracking, unsubscribe } from './tracking.js';
14
+ import { activeOutgoingRoots, outgoingRootOf, isRemountTrigger } from './staging.js';
15
+ import {
16
+ scopedOverlayOf,
17
+ rememberOverlay,
18
+ overlayKeysOf,
19
+ evalInScope,
20
+ resolveThisPath,
21
+ } from './utils.js';
22
+ import { resolvePath } from './iteration-utils.js';
23
+
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
+ export const scopeFor = (sub, base) => {
29
+ const overlay = sub.lastScope ? scopedOverlayOf(sub.lastScope) : null;
30
+ if (!overlay) return base;
31
+ const merged = { ...base, ...overlay };
32
+ rememberOverlay(merged, overlay);
33
+ return merged;
34
+ };
35
+
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
+ export const rowsShallowEqual = (instances, newArray) =>
42
+ instances.length === newArray.length &&
43
+ instances.every((inst, i) => inst.item === newArray[i]);
44
+
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
+ export const entriesForText = (sub, scope) => {
49
+ const e = sub.entry;
50
+ const group = e.binding?._group ?? [e.binding];
51
+ return group.map((m) => ({
52
+ matchOuter: m.outer,
53
+ matchInner: m.inner,
54
+ input: m.input,
55
+ element: e.element,
56
+ textNode: e.textNode,
57
+ scopedState: scope,
58
+ binding: m,
59
+ }));
60
+ };
61
+
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
+ const docOrder = (a, b) => {
65
+ const x = a.anchor;
66
+ const y = b.anchor;
67
+ if (x === y || !x?.compareDocumentPosition || !y) return 0;
68
+ const pos = x.compareDocumentPosition(y);
69
+ if (pos & 4) return -1;
70
+ if (pos & 2) return 1;
71
+ return 0;
72
+ };
73
+
74
+ const dispatchIteration = (sub, currentState, previousState, manifest) => {
75
+ const node = sub.node;
76
+ // Not yet rendered — the mount path owns first render.
77
+ if (!node.runtime.instances || !node.runtime.templateRemoved) return;
78
+ const scope = scopeFor(sub, currentState);
79
+ const parentEl = node.meta.startComment?.parentElement;
80
+ const resolvedExpr = resolveThisPath(node.meta.arrayPath, parentEl);
81
+ beginTracking(sub, overlayKeysOf(scope));
82
+ const newArray =
83
+ evalInScope(resolvedExpr, scope, parentEl) ?? resolvePath(scope, resolvedExpr);
84
+ endTracking();
85
+ const instances = node.runtime.instances;
86
+ 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
+ if (!treeless && Array.isArray(newArray) && rowsShallowEqual(instances, newArray)) return;
91
+ updateIteration(node, scope, scopeFor(sub, previousState), manifest);
92
+ };
93
+
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
+ export default (dirty, currentState, previousState, manifest) => {
99
+ 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
+ const triggers = [];
106
+ const rest = [];
107
+ for (const sub of subs) {
108
+ if (sub.anchor && !sub.anchor.isConnected) {
109
+ unsubscribe(sub);
110
+ continue;
111
+ }
112
+ // Already-frozen subtree (a staging window spanning flushes): skip.
113
+ if (activeOutgoingRoots.size && outgoingRootOf(sub.anchor)) continue;
114
+ (sub.entry && isRemountTrigger(sub.entry) ? triggers : rest).push(sub);
115
+ }
116
+ if (triggers.length > 0) {
117
+ hydrate(
118
+ triggers.map((s) => ({ ...s.entry, scopedState: scopeFor(s, currentState) })),
119
+ currentState,
120
+ manifest,
121
+ previousState,
122
+ );
123
+ }
124
+ const seenGroups = new Set();
125
+ let batch = [];
126
+ const flushBatch = () => {
127
+ if (batch.length > 0) {
128
+ hydrate(batch, currentState, manifest, previousState);
129
+ batch = [];
130
+ }
131
+ };
132
+ 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
+ if (sub.anchor && !sub.anchor.isConnected) {
137
+ unsubscribe(sub);
138
+ continue;
139
+ }
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
+ if (activeOutgoingRoots.size && outgoingRootOf(sub.anchor)) continue;
147
+ if (sub.kind === 'conditional') {
148
+ flushBatch();
149
+ dispatchConditional(sub.node, scopeFor(sub, currentState), manifest);
150
+ continue;
151
+ }
152
+ if (sub.kind === 'iteration') {
153
+ flushBatch();
154
+ dispatchIteration(sub, currentState, previousState, manifest);
155
+ continue;
156
+ }
157
+ const e = sub.entry;
158
+ if (!e) continue;
159
+ const scope = scopeFor(sub, currentState);
160
+ if (e.type === 'attribute' || e.type === 'nameBinding') {
161
+ batch.push({ ...e, scopedState: scope });
162
+ } else {
163
+ const group = e.binding?._group;
164
+ if (group) {
165
+ if (seenGroups.has(group)) continue;
166
+ seenGroups.add(group);
167
+ }
168
+ batch.push(...entriesForText(sub, scope));
169
+ }
170
+ }
171
+ flushBatch();
172
+ };