@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.
@@ -27,60 +27,29 @@ import {
27
27
 
28
28
  export { activeOutgoingRoots, isComponentWrapper };
29
29
 
30
- // Deterministic component counter
31
- // Runtime-minted ids live in their own range, far above every build-tagged
32
- // id (SPA units get 1000 ids each — 1M covers a thousand pages). Without the
33
- // floor, a long session's runtime counter could grow into a not-yet-mounted
34
- // fragment's build range and claim its state. advanceComponentCounterPastIds
35
- // still bumps past anything larger it encounters in the document.
36
30
  let componentCounter = 1000000;
37
31
 
38
- /**
39
- * Generate unique component ID
40
- * Uses deterministic counter: _c0, _c1, _c2, etc.
41
- */
42
32
  export const generateComponentId = () => {
43
33
  return `_c${componentCounter++}`;
44
34
  };
45
35
 
46
- // Helper to escape regex special characters
47
36
  const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
48
37
 
49
- // Kept as no-ops for API compatibility — path-based tracking was replaced by
50
- // DOM-scan cleanup in releaseOrphanedComponentState. Manifest paths don't
51
- // align 1:1 with component ownership across conditional remounts, so scanning
52
- // the DOM subtree about to be removed is simpler and waterproof.
53
38
  export const trackComponentOwnership = () => {};
54
39
 
55
- // Given an iterable of DOM nodes that are about to be (or have just been)
56
- // removed, find every `data-vibe-component-id` inside them and, for each id
57
- // whose DOM is fully gone from the live document, evict its state.
58
- //
59
- // Call this AFTER the nodes have been detached from the document so the
60
- // `document.querySelector` check sees the post-removal state.
61
40
  export const releaseOrphanedComponentState = (collectedIds) => {
62
41
  if (!collectedIds || collectedIds.size === 0) return;
63
42
  for (const id of collectedIds) {
64
43
  if (document.querySelector(`[data-vibe-component-id="${id}"]`)) continue;
65
44
  delete window.__vibe?.components?.[id];
66
- // CLEANUP OF CURRENT STATE
67
45
  delete window.$[id];
68
46
  runComponentCleanups(id);
69
47
  }
70
48
  };
71
49
 
72
- // Listener registry: maps componentId → array of unsubscribe callbacks
73
- // returned from `$.on(...)` calls inside the component's `<script>`.
74
- // Re-running a script for the same id (HMR remount with reused ids) or
75
- // unmounting the component fires the callbacks so the previous evaluation's
76
- // listeners don't accumulate alongside fresh registrations.
77
50
  export const runComponentCleanups = (componentId) => {
78
51
  const cleanups = window.__vibe?.cleanups?.[componentId];
79
52
  if (!cleanups) return;
80
- // Deregister BEFORE running: a throwing callback must neither re-throw on
81
- // every later cleanup of this id nor take the remaining callbacks down
82
- // with it — and never escape into the script chain, whose links have no
83
- // rejection path. User callbacks fail loudly, the engine keeps going.
84
53
  delete window.__vibe.cleanups[componentId];
85
54
  for (let i = 0; i < cleanups.length; i++) {
86
55
  try {
@@ -91,11 +60,6 @@ export const runComponentCleanups = (componentId) => {
91
60
  }
92
61
  };
93
62
 
94
- // Per-script `$` Proxy. Bare `$` references in a component's <script> resolve
95
- // to this Proxy (the function parameter shadows the global), so every
96
- // `$.on(...)` registration is automatically attributed to `componentId` via
97
- // the closure — no global flag, async-safe across `await` boundaries because
98
- // the closure binds the id, not a shared variable.
99
63
  const createScopedDollar = (componentId) => {
100
64
  const dollar = window.$;
101
65
  if (!dollar) return dollar;
@@ -103,14 +67,6 @@ const createScopedDollar = (componentId) => {
103
67
  get(target, prop, receiver) {
104
68
  if (prop === 'on') {
105
69
  return (event, callback) => {
106
- // 'unmount' is scope-resolved: in a component script it means THIS
107
- // component's unmount (conditional toggle, iteration removal,
108
- // reactive src swap — and before an HMR re-run of the same id).
109
- // The callback rides the same per-component cleanup registry the
110
- // global-event unsubscribes below ride, so a component owns
111
- // arbitrary side effects (intervals, listeners, sockets) without
112
- // leaking them past its lifetime. At page level the root's `on`
113
- // resolves the same event name to pagehide instead.
114
70
  if (event === 'unmount') {
115
71
  const cleanupsReg = ((window.__vibe ??= {}).cleanups ??= {});
116
72
  const slot = cleanupsReg[componentId] || (cleanupsReg[componentId] = []);
@@ -135,8 +91,6 @@ const createScopedDollar = (componentId) => {
135
91
  });
136
92
  };
137
93
 
138
- // Walk a node subtree (element or node list) and collect all
139
- // `data-vibe-component-id` values found on the node and its descendants.
140
94
  export const collectComponentIds = (node, into = new Set()) => {
141
95
  if (!node) return into;
142
96
  if (node.nodeType === 1) {
@@ -149,25 +103,15 @@ export const collectComponentIds = (node, into = new Set()) => {
149
103
  return into;
150
104
  };
151
105
 
152
- // Prepare a component <script> body for execution through the injected
153
- // component() path: strip the `import component from '...'` line (the
154
- // function is passed in as a parameter) and rewrite any remaining static
155
- // imports to awaited dynamic imports. Shared by the fetch path
156
- // (processSingle) and the compiled-page path (executeCompiledComponentScripts).
157
106
  const transformScriptContent = (rawContent) => {
158
- // Strip `import component from '...'` — Vibe injects the contextual
159
- // component() function as a parameter (it needs access to the temp DOM)
160
107
  let content = rawContent.replace(
161
108
  /import\s+component\s+from\s+['"][^'"]+['"];?\s*/g,
162
109
  ''
163
110
  );
164
111
 
165
- // Check for remaining imports that need rewriting
166
112
  const hasImports = /import\s/.test(content);
167
113
 
168
114
  if (hasImports) {
169
- // Rewrite remaining imports to dynamic await import()
170
- // Order matters: combined → default → named → namespace → side-effect (most specific first)
171
115
  content = content.replace(
172
116
  /import\s+(\w+)\s*,\s*\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
173
117
  'const __m_$1 = await import($3); const $1 = __m_$1.default; const {$2} = __m_$1;'
@@ -193,24 +137,6 @@ const transformScriptContent = (rawContent) => {
193
137
  return { content, hasImports };
194
138
  };
195
139
 
196
- // Compiled pages inline `<component src>` content at build time, and the
197
- // compiler neuters each component script to type="vibe-module" so the browser
198
- // does NOT execute it as a native page module — native timing is wrong
199
- // (pre-boot: `$` is the placeholder, state registered after boot never merges
200
- // into the live proxy). This executes those scripts through the same
201
- // injected-component() path processSingle uses for fetched scripts: one
202
- // pipeline, identical semantics in both modes. Scripts stay in the DOM
203
- // (inert) so the manifest's childNodes indices keep matching the page.
204
- //
205
- // Returns a Promise when any script is async (has imports) — the caller
206
- // gates `ready` on it — or null when everything ran synchronously.
207
- //
208
- // Collection is span-aware, not a blind document query: manifest adoption has
209
- // already swapped every directive's stamped content back to its pre-stamp
210
- // template (declaration form), so at sweep time ALL content between
211
- // `<!-- if -->`/`<!-- each -->` markers is unrendered template. Those scripts
212
- // run when their branch/row mounts (mountBranch / renderIteration call back
213
- // into this pipeline); only directive-free scripts are the boot pass's own.
214
140
  export const executeCompiledComponentScripts = () => {
215
141
  const scripts = [];
216
142
  collectMountedModuleScripts(document.body ? [document.body] : [], scripts);
@@ -219,21 +145,6 @@ export const executeCompiledComponentScripts = () => {
219
145
  return runVibeModuleScripts(scripts);
220
146
  };
221
147
 
222
- // Same pipeline as the boot-time pass, but scoped to a freshly mounted subtree
223
- // (a conditional branch or iteration row) rather than the whole document. The
224
- // boot pass only sees component scripts that are in the page at boot; a branch
225
- // that mounts later — or RE-mounts after being unmounted — carries its own
226
- // inlined `vibe-module` scripts that must run each time so component-local state
227
- // (`component({...})` → `$._cN`) is re-registered. Without this, a re-opened
228
- // conditional restores its markup but its `<!-- each _cN.x -->` reads state that
229
- // was released on unmount (the AccountProgression overlay rendering blank on
230
- // second open).
231
- //
232
- // `silent` routes fresh-id registrations through $.register (no global flush)
233
- // — correct ONLY when the subtree's own hydration runs after these scripts
234
- // against live `$` (the fetched-mount path). Branch/row mounts render against
235
- // a flush snapshot that predates the registration, so they rely on the
236
- // registration's own flush to fill `_cN` bindings in — they must stay loud.
237
148
  export const executeCompiledComponentScriptsIn = (nodes, { silent = false } = {}) => {
238
149
  const scripts = [];
239
150
  collectMountedModuleScripts(nodes, scripts);
@@ -242,16 +153,6 @@ export const executeCompiledComponentScriptsIn = (nodes, { silent = false } = {}
242
153
  return runVibeModuleScripts(scripts, silent);
243
154
  };
244
155
 
245
- // Collect the vibe-module scripts a fresh mount actually OWNS. Content between
246
- // nested `<!-- if -->`/`<!-- each -->` markers in a just-cloned subtree is an
247
- // unrendered template — its scripts belong to the nested directive and run when
248
- // THAT directive mounts its branch/rows (mountBranch and renderIteration each
249
- // call back into this pass). Running them here would execute component copies
250
- // this mount never shows: the compiler inlines a slotted component once per
251
- // conditional branch, so a mount that owns both branch templates would register
252
- // ghost state buckets whose `$.on` hooks shadow the mounted copy's writes
253
- // forever. Directive spans are sibling-scoped, so each child list scans with
254
- // its own depth counter (same walk as reconcile's opaque-region skip).
255
156
  const collectMountedModuleScripts = (nodes, out) => {
256
157
  let depth = 0;
257
158
  for (const node of nodes) {
@@ -267,8 +168,6 @@ const collectMountedModuleScripts = (nodes, out) => {
267
168
  }
268
169
  };
269
170
 
270
- // Build-time tagging already assigned _cN ids to wrappers; advance the runtime
271
- // counter past them so freshly generated ids never collide.
272
171
  const advanceComponentCounterPastIds = (root) => {
273
172
  root.querySelectorAll('[data-vibe-component-id]').forEach((el) => {
274
173
  const m = el.getAttribute('data-vibe-component-id')?.match(/^_c(\d+)$/);
@@ -276,11 +175,6 @@ const advanceComponentCounterPastIds = (root) => {
276
175
  });
277
176
  };
278
177
 
279
- // Shared registration: the component registry always, plus the live `$` when
280
- // booted. `silent` prefers $.register — a fresh id lands without its own
281
- // global flush; the MOUNT commits all its registrations in one notifyChanged
282
- // batch when its scripts settle. Re-registrations (HMR) still flush
283
- // immediately, and pre-boot placeholders keep the plain reactive write.
284
178
  const registerComponentState = (componentId, state, silent) => {
285
179
  ((window.__vibe ??= {}).components ??= {})[componentId] = state;
286
180
  if (window.$) {
@@ -289,18 +183,6 @@ const registerComponentState = (componentId, state, silent) => {
289
183
  }
290
184
  };
291
185
 
292
- // Native `<script type="module">` semantics: module scripts evaluate strictly
293
- // in document order — script N+1 never starts before script N finished,
294
- // imports included. A sync script may therefore rely on globals an earlier
295
- // script's import produced (the game's Layout imports boot.js; chrome
296
- // components read its globals at their top level). Eval'd vibe-module scripts
297
- // lose that for free — an async function returns at its first `await`, so the
298
- // executor's loop would start every script "concurrently" and sync scripts
299
- // would run before earlier imports landed. One global chain restores the
300
- // contract across ALL execution sites (boot batch, mounted subtrees, fetched
301
- // components): while a script is pending, later scripts — sync ones too —
302
- // queue behind it. An idle chain runs sync scripts synchronously, so
303
- // boot timing is unchanged until the first async script appears.
304
186
  let scriptChain = null;
305
187
 
306
188
  const enqueueScriptExecution = (run) => {
@@ -321,10 +203,6 @@ const enqueueScriptExecution = (run) => {
321
203
  return result;
322
204
  };
323
205
 
324
- // Cleanups, scoping and evaluation for one script — the unit the chain
325
- // executes. Never throws and never rejects: a crashed script is warned and
326
- // released, later scripts still run (a failed native module doesn't stop
327
- // subsequent script elements either).
328
206
  const executeScriptUnit = (componentId, content, hasImports, scopedDollar, componentFn) => () => {
329
207
  runComponentCleanups(componentId);
330
208
  try {
@@ -340,13 +218,6 @@ const executeScriptUnit = (componentId, content, hasImports, scopedDollar, compo
340
218
  }
341
219
  };
342
220
 
343
- // The wrapper's own setup script: the first vibe-module script in the wrapper's
344
- // subtree not owned by a nested component wrapper. Only THIS script may claim
345
- // the wrapper's build-tagged id — the id the stamped bindings reference — and
346
- // the rule must be deterministic across batches, not "first in this batch": a
347
- // later partial batch (a nested branch mount re-running one auxiliary script)
348
- // claiming the component's id would tear down the component's live `$.on` hooks
349
- // through executeScriptUnit's pre-run cleanup and never re-register them.
350
221
  const wrapperSetupScript = (wrapper) => {
351
222
  for (const s of wrapper.querySelectorAll('script[type="vibe-module"]')) {
352
223
  if (s.closest('[data-vibe-component-id]') === wrapper) return s;
@@ -359,14 +230,8 @@ const runVibeModuleScripts = (scripts, silent = false) => {
359
230
  const registeredIds = [];
360
231
 
361
232
  for (const script of scripts) {
362
- // One script node, one execution — native `<script>` semantics. Remounts
363
- // clone fresh nodes from the restoration template (clones don't carry the
364
- // marker), so they run; the same DOM node reached by overlapping passes
365
- // (boot's document sweep after a pre-boot branch mount already ran it)
366
- // does not run twice.
367
233
  if (script.__vibeExecuted) continue;
368
234
 
369
- // Parity with the fetch path: dehydrated components never execute
370
235
  if (script.closest(`[${DEHYDRATE_CLASS_OR_ATTR}], .${DEHYDRATE_CLASS_OR_ATTR}`)) continue;
371
236
 
372
237
  const rawContent = script.textContent?.trim() || '';
@@ -393,8 +258,6 @@ const runVibeModuleScripts = (scripts, silent = false) => {
393
258
  if (task) asyncTasks.push(task);
394
259
  }
395
260
 
396
- // Silent mode: one grouped commit once every script has registered, so
397
- // whole-`$` observers see all fresh keys with a single flush.
398
261
  if (silent) {
399
262
  if (asyncTasks.length) {
400
263
  return Promise.all(asyncTasks).then(() => notifyChanged(registeredIds));
@@ -406,20 +269,12 @@ const runVibeModuleScripts = (scripts, silent = false) => {
406
269
  return asyncTasks.length ? Promise.all(asyncTasks) : null;
407
270
  };
408
271
 
409
- // Track pending fetches to cancel them if element is removed
410
- const pendingFetches = new WeakMap(); // element → AbortController
272
+ const pendingFetches = new WeakMap();
411
273
 
412
- // Component HTML is parsed (and prop/slot-transformed) while its @[...]
413
- // bindings are still literal text. Parsing in the live document lets the
414
- // browser act on those literals mid-parse — Chrome logs "The specified value
415
- // ... cannot be parsed" for a typed input's value="@[...]". An inert document
416
- // (no browsing context) parses identical DOM without a console to complain to;
417
- // nodes are auto-adopted into the live document on insertion.
418
274
  let inertDocument;
419
275
  const createDetached = (tagName) =>
420
276
  (inertDocument ??= document.implementation.createHTMLDocument('')).createElement(tagName);
421
277
 
422
- // Cancel a pending fetch for a component element
423
278
  export const abortComponentFetch = (element) => {
424
279
  const controller = pendingFetches.get(element);
425
280
  if (controller) {
@@ -428,63 +283,34 @@ export const abortComponentFetch = (element) => {
428
283
  }
429
284
  };
430
285
 
431
- // The live element a reactive src binding acts on. The manifest tree keeps the
432
- // ORIGINAL element, but every (re)mount replaces the wrapper — staging.js's
433
- // chain resolver is the one implementation.
434
286
  export const liveComponentWrapper = liveNode;
435
287
 
436
- // (Re)mount a component for a reactive src binding (`src="@[page.src]"`).
437
- // Three phases of a wrapper's life, one entry point:
438
- // - Unprocessed element (no fetch yet): write the resolved src — the pending
439
- // boot/observer processComponent pass fetches it.
440
- // - Fetch in flight: abort it and fetch the new src.
441
- // - Mounted wrapper (src consumed by finalize): re-fetch and re-mount; the
442
- // authored props + slot content re-apply via the remount context finalize
443
- // stashed on the wrapper, and the outgoing component's state is evicted by
444
- // the removal pass when replaceWith drops the old wrapper.
445
288
  export const remountComponent = (el, src, debug = false) => {
446
289
  const wasFetching = pendingFetches.has(el);
447
290
  const hadSrcAttr = el.hasAttribute('src');
448
- // Compare against the LATEST requested src: with a fetch in flight the src
449
- // attribute holds it (rapid navigation A→B→A must abort B, not no-op on A);
450
- // mounted and idle, the finalize-stashed value does.
451
291
  const current = wasFetching
452
292
  ? el.getAttribute('src')
453
293
  : (el._vibeMountedSrc ?? el.getAttribute('src'));
454
294
  if (src === current) return;
455
295
  abortComponentFetch(el);
456
296
  el.setAttribute('src', src);
457
- // Pre-fetch element awaiting its initial processing pass: that pass reads
458
- // the new value — nothing to redo. A declaration-form wrapper (bound src
459
- // that resolved to nothing, carried on data-vibe-src with no src
460
- // attribute) was invisible to that pass, so hydration owns its first
461
- // fetch too — the observer doesn't watch attributes.
462
297
  if (el._vibeMountedSrc === undefined && !wasFetching && hadSrcAttr) return;
463
- // The mounted content is now OUTGOING: it will be replaced wholesale when
464
- // the new src lands, so re-rendering it against post-navigation state is
465
- // pure waste — and is what made outgoing pages visually collapse the
466
- // moment route state flipped. Both engines skip beneath a flagged wrapper
467
- // (the wrapper's own src/key bindings stay live so a rapid next navigation
468
- // still re-triggers). The flag dies with the wrapper at swap.
469
298
  markOutgoing(el);
470
299
  processSingle(el, debug);
471
300
  };
472
301
 
473
- // Key-change remount (`key="@[page.path]"`): mount the CURRENT src fresh even
474
- // though it is unchanged. Only a mounted, idle wrapper has anything to redo —
475
- // with a fetch pending the incoming mount is already fresh (a src change in
476
- // the same flush started it), and an unmounted wrapper's first mount is owned
477
- // by the normal processing pass.
478
302
  export const forceRemount = (el, debug = false) => {
479
303
  if (pendingFetches.has(el) || el._vibeMountedSrc === undefined) return;
480
304
  el.setAttribute('src', el._vibeMountedSrc);
481
- // Same outgoing freeze as remountComponent — a keyed remount replaces the
482
- // mounted content just the same.
483
305
  markOutgoing(el);
484
306
  processSingle(el, debug);
485
307
  };
486
308
 
487
- // Check if an element is nested inside another unprocessed component[src]
309
+ const hasUnresolvedSrc = (el) => {
310
+ BINDING_REGEX.lastIndex = 0;
311
+ return BINDING_REGEX.test(el.getAttribute('src'));
312
+ };
313
+
488
314
  const isNestedInUnprocessedComponent = (el, rootElement) => {
489
315
  let parent = el.parentElement;
490
316
  while (parent && parent !== rootElement) {
@@ -499,17 +325,6 @@ const isNestedInUnprocessedComponent = (el, rootElement) => {
499
325
  return false;
500
326
  };
501
327
 
502
- // Rewrite `@[this.x...]` and `$.this.x...` inside element's text/attrs to use
503
- // componentId. Shared between script-execution path and pure-render path.
504
- //
505
- // Within `@[...]` bindings we rewrite every `this.X` reference (preserving
506
- // any trailing `.Y.Z` chain), so `@[this.user.name]` and `@[this.x + 1]` both
507
- // resolve correctly. Outside bindings — i.e. event handler attribute bodies
508
- // like `onclick="$.this.mode = 'edit'"` — only `$.this.X` writes are
509
- // rewritten; bare `this.X` in event handlers is handled later by parse.js,
510
- // which lowers it to `$this(this).X` — resolved at fire time by
511
- // this-scope.js (declared state keys hit the bucket, everything else stays
512
- // the native element).
513
328
  const rewriteBindingsInString = (str, componentId) =>
514
329
  str.replace(BINDING_REGEX, (match, expr) => {
515
330
  const rewritten = expr.replace(THIS_PROP_REGEX, `${componentId}.$1`);
@@ -537,20 +352,12 @@ const rewriteThisBindings = (element, componentId) => {
537
352
  });
538
353
  };
539
354
 
540
- // Tag siblings of a <script> with componentId and rewrite `this.` bindings.
541
- // Runs BEFORE script.remove() so nextElementSibling is valid. Shared by
542
- // processSingle (executing path) and renderComponentTemplate (pure path).
543
355
  const tagScriptSiblings = (script, componentId) => {
544
356
  let sibling = script.nextElementSibling;
545
357
  while (sibling) {
546
358
  if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
547
359
  break;
548
360
  }
549
- // A sibling already carrying an id is a build-tagged compiled wrapper
550
- // that owns its scope — its `this.` bindings were rewritten to that id
551
- // at compile time. Overwriting it re-scopes the wrapper to THIS script's
552
- // id and orphans every `@[_cN.x]` binding inside it (the game's
553
- // top-level create-teamplay modal losing its state).
554
361
  if (!sibling.hasAttribute('data-vibe-component-id')) {
555
362
  sibling.setAttribute('data-vibe-component-id', componentId);
556
363
  rewriteThisBindings(sibling, componentId);
@@ -559,21 +366,12 @@ const tagScriptSiblings = (script, componentId) => {
559
366
  }
560
367
  };
561
368
 
562
- // Substitute props + inline slot content into a pre-processed temp container.
563
- // Returns the final processed HTML string. Shared by processSingle.finalize()
564
- // (runtime mount) and renderComponentTemplate (surgical HMR).
565
369
  const renderPropsAndSlot = (temp, props, slotHtml) => {
566
370
  let transformedHtml = temp.innerHTML;
567
371
 
568
372
  const DIRECTIVE_COMMENT_REGEX = /<!--\s*(if|else if|each)\s+([^]*?)\s*-->/g;
569
373
  const EVENT_ATTR_REGEX = /\bon(\w+)="([^"]*)"/g;
570
374
 
571
- // HTML's parser lowercases attribute names, so a consumer-written
572
- // `<component dndDisabled>` arrives here with propName `dnddisabled` while
573
- // the component template author wrote `dndDisabled`. Match identifiers and
574
- // bindings case-insensitively so both sides line up. Word-boundary
575
- // lookbehind/lookahead still hold (they're case-agnostic), so
576
- // `dnddisabled` won't bleed into `dndDisabledAlt`.
577
375
  Object.entries(props).forEach(([propName, propValue]) => {
578
376
  const bindingMatch = propValue.match(/^@\[(.+)\]$/);
579
377
  const exactPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'gi');
@@ -582,11 +380,6 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
582
380
  'gi'
583
381
  );
584
382
 
585
- // Rewrite the identifier only outside string literals. A prop/alias that also
586
- // appears inside a quoted string — e.g. the selector in `closest('brawler-slot')`
587
- // when the prop is `slot` — must be left intact. Split on string literals
588
- // (single/double/backtick, honouring escapes) and substitute only the code spans
589
- // between them. Template `${…}` interpolation counts as part of the literal here.
590
383
  const stringLiteral = /(['"`])(?:\\.|(?!\1)[^\\])*\1/g;
591
384
  const substituteInExpr = (expr, replacement) => {
592
385
  let out = '';
@@ -617,11 +410,6 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
617
410
  );
618
411
  const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'gi');
619
412
  transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
620
- // Substitute the bare prop identifier too (not just the `$.prop` form),
621
- // mirroring the @[...] binding pass above — so `onclick="pick(item)"`
622
- // resolves the live prop, not a global that throws ReferenceError. A
623
- // loop-alias path (`row.sig`) becomes `(row.sig)` here; parse.js then
624
- // rewrites the alias to `$scope(this,'row')`.
625
413
  const rewritten = substituteInExpr(body.replace(stateRegex, `$.${path}`), `(${path})`);
626
414
  return rewritten === body ? match : `on${evName}="${rewritten}"`;
627
415
  });
@@ -648,8 +436,6 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
648
436
  );
649
437
  const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'gi');
650
438
  transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
651
- // Same bare-identifier substitution for a literal prop: `onclick="set(limit)"`
652
- // with `limit="5"` becomes `set(5)`.
653
439
  const rewritten = substituteInExpr(body.replace(stateRegex, `$.${literal}`), literal);
654
440
  return rewritten === body ? match : `on${evName}="${rewritten}"`;
655
441
  });
@@ -671,12 +457,6 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
671
457
  return transformedHtml;
672
458
  };
673
459
 
674
- // Pure-render path for surgical HMR. Takes a component's raw template HTML
675
- // plus the live instance's props, slot, and existing componentIds. Returns
676
- // the processed HTML string that $.reconcile can diff against the live
677
- // wrapper's children. Scripts are NOT executed — callers are responsible
678
- // for deciding whether to preserve the existing state (reuse componentIds)
679
- // or trigger a full re-mount (new componentIds).
680
460
  export const renderComponentTemplate = (rawHtml, props = {}, slotHtml = '', options = {}) => {
681
461
  const { componentIds = [] } = options;
682
462
  const temp = createDetached('div');
@@ -693,31 +473,19 @@ export const renderComponentTemplate = (rawHtml, props = {}, slotHtml = '', opti
693
473
  return renderPropsAndSlot(temp, props, slotHtml);
694
474
  };
695
475
 
696
- // Process a single component element: fetch HTML, execute scripts, replace DOM
697
476
  const processSingle = (el, debug) => {
698
- // Skip dehydrated components
699
477
  if (el.hasAttribute(DEHYDRATE_CLASS_OR_ATTR) || el.classList.contains(DEHYDRATE_CLASS_OR_ATTR)) {
700
478
  return Promise.resolve();
701
479
  }
702
480
 
703
- // Skip if fetch already in flight for this element
704
481
  if (pendingFetches.has(el)) return Promise.resolve();
705
482
 
706
483
  const src = el.getAttribute('src');
707
484
 
708
- // Use pre-hydration slot content if available (saved by index.js before
709
- // hydration ran, or re-stashed by finalize for reactive-src re-mounts),
710
- // otherwise fall back to current innerHTML (e.g. runtime-only usage without
711
- // boot). Kept on the element — a re-mount consumes the same authored slot.
712
485
  const children = (el._vibeSlotContent !== undefined ? el._vibeSlotContent : el.innerHTML).trim();
713
- // A re-mounted wrapper carries no prop attributes (finalize stripped them) —
714
- // its authored props ride the remount context stashed at the previous mount.
715
486
  let props = el._vibeRemountProps;
716
487
  if (!props) {
717
488
  props = {};
718
- // `src` and `key` are the wrapper's own contract (what to mount / when to
719
- // remount), and data-vibe-* attributes are runtime transport — none of
720
- // them are authored props for the component.
721
489
  Array.from(el.attributes).forEach((attr) => {
722
490
  if (attr.name !== 'src' && attr.name !== 'key' && !attr.name.startsWith('data-vibe-')) {
723
491
  props[attr.name] = attr.value;
@@ -725,47 +493,24 @@ const processSingle = (el, debug) => {
725
493
  });
726
494
  }
727
495
 
728
- // Capture cache state before the fetch so the debug layer can tell a real
729
- // network fetch from a runtime-cache hit (the call below would make them
730
- // indistinguishable — both just resolve a promise).
731
496
  const fromCache = isComponentCached(src);
732
497
 
733
- // Create AbortController to cancel fetch if element is removed
734
498
  const controller = new AbortController();
735
499
  pendingFetches.set(el, controller);
736
500
 
737
501
  return fetchComponentTemplate(src, controller.signal)
738
502
  .then((html) => {
739
- // Superseded before the template arrived: the cache path resolves
740
- // regardless of the abort signal (aborting a cache hit is meaningless
741
- // network-wise), so the supersede check lives here. A newer remount
742
- // owns the wrapper — running this mount's scripts or finalize would
743
- // land the stale fragment and discard the new one.
744
503
  if (controller.signal.aborted) return;
745
504
 
746
- // Parse HTML in temporary container to process component scripts
747
505
  const temp = createDetached('div');
748
506
  temp.innerHTML = html;
749
507
 
750
- // Process any <script type="module"> elements
751
508
  const moduleScripts = temp.querySelectorAll('script[type="module"]');
752
509
 
753
- // Process each script — collect async tasks if any have imports
754
510
  const asyncTasks = [];
755
511
 
756
- // Track every componentId registered during this fetch. If the host
757
- // element is detached before finalize runs (e.g. a conditional unmounted
758
- // mid-fetch, or the user navigated away), we release these state
759
- // buckets — otherwise component({...}) leaks state to `$` for DOM that
760
- // never reaches the document.
761
512
  const registeredComponentIds = [];
762
513
 
763
- // First componentId encountered — applied to the wrapper itself so
764
- // directives living between top-level sibling roots (e.g. a comment
765
- // marker for `<!-- if this.X -->`) can resolve component scope via
766
- // closest('[data-vibe-component-id]'). Without this, multi-root
767
- // templates have scope-less wrappers and top-level `this.` references
768
- // fall through to global state.
769
514
  let firstComponentId = null;
770
515
 
771
516
  for (const script of moduleScripts) {
@@ -774,54 +519,29 @@ const processSingle = (el, debug) => {
774
519
 
775
520
  const { content: scriptContent, hasImports } = transformScriptContent(rawContent);
776
521
 
777
- // Reuse component ID from HMR if available, otherwise generate new
778
522
  const reuseIds = el._vibeReuseComponentIds;
779
523
  const componentId = reuseIds && reuseIds.length ? reuseIds.shift() : generateComponentId();
780
524
 
781
525
  if (firstComponentId === null) firstComponentId = componentId;
782
526
 
783
- // Provide a component() function that registers state for this component.
784
- // Sibling tagging is handled below (before script.remove()) so it works
785
- // for both sync and async scripts.
786
527
  const componentFn = (state) => {
787
- // Fetched-mount registration is silent: the fetched subtree's
788
- // hydration runs after replaceWith against live `$`, so a fresh id
789
- // needs no flush (see registerComponentState / $.register).
790
528
  registerComponentState(componentId, state, true);
791
529
  if (!registeredComponentIds.includes(componentId)) {
792
530
  registeredComponentIds.push(componentId);
793
531
  }
794
- // Return the id so consumers can reach their reactive state via
795
- // `$[id]` — matches the public component.js contract. Without this,
796
- // `const id = component(state)` is undefined for src-fetched
797
- // components and `$[id]` silently resolves to nothing.
798
532
  return componentId;
799
533
  };
800
534
 
801
- // Execute through the global script chain (see enqueueScriptExecution):
802
- // cleanups for a reused componentId (HMR remount) tear down the prior
803
- // evaluation's listeners right before the fresh run, the scoped `$`
804
- // tracks this script's `.on(...)` registrations, and document-order
805
- // execution holds even against scripts from other batches (a fetched
806
- // component's script never runs while the shell's boot import is
807
- // still in flight).
808
535
  const task = enqueueScriptExecution(
809
536
  executeScriptUnit(componentId, scriptContent, hasImports, createScopedDollar(componentId), componentFn),
810
537
  );
811
538
  if (task) asyncTasks.push(task);
812
539
 
813
- // Tag siblings + rewrite this. bindings using shared helper. Runs
814
- // BEFORE script.remove() so nextElementSibling is valid.
815
540
  tagScriptSiblings(script, componentId);
816
541
 
817
- // Remove script from temp (we executed it manually)
818
542
  script.remove();
819
543
  }
820
544
 
821
- // Evict everything this mount's scripts registered — state buckets AND
822
- // their $.on listeners/unmount side effects. The scripts already RAN
823
- // (execution precedes finalize), so skipping the cleanups here leaks
824
- // live global listeners for DOM that never mounts.
825
545
  const releaseRegisteredIds = () => {
826
546
  for (const id of registeredComponentIds) {
827
547
  delete window.__vibe?.components?.[id];
@@ -830,27 +550,17 @@ const processSingle = (el, debug) => {
830
550
  }
831
551
  };
832
552
 
833
- // Finalize: props, slots, DOM replacement
834
553
  const finalize = () => {
835
- // Superseded DURING script execution (an async import held this mount
836
- // while a newer remount aborted it): the newer fetch owns the wrapper.
837
554
  if (controller.signal.aborted) {
838
555
  releaseRegisteredIds();
839
556
  return;
840
557
  }
841
558
 
842
- // Delegate prop substitution + slot inlining to shared helper.
843
559
  const transformedHtml = renderPropsAndSlot(temp, props, children);
844
560
 
845
- // Clean up pending fetch tracker — only if this fetch still owns the
846
- // slot (a reactive-src re-mount may have aborted us and registered a
847
- // newer controller for the same element).
848
561
  if (pendingFetches.get(el) === controller) pendingFetches.delete(el);
849
562
 
850
- // Replace with clean component wrapper (no src, no props)
851
- // Check if element still has a parent (might have been removed during fetch)
852
563
  if (el.parentNode) {
853
- // Create clean wrapper element (preserve tag type: component or div.component)
854
564
  const newWrapper =
855
565
  el.tagName === 'DIV' ? createDetached('div') : createDetached('component');
856
566
 
@@ -859,127 +569,47 @@ const processSingle = (el, debug) => {
859
569
  }
860
570
 
861
571
  newWrapper.innerHTML = transformedHtml;
862
- // Park binding-valued src while the wrapper still lives in the inert
863
- // document — adoption into the live document is what starts image
864
- // loads, so this is the last moment a raw `@[...]` src is harmless.
865
572
  parkFetchableSrc(newWrapper);
866
573
  if (firstComponentId !== null) {
867
574
  newWrapper.setAttribute('data-vibe-component-id', firstComponentId);
868
575
  }
869
- // Stash the raw source so the HMR plugin can establish a baseline
870
- // script hash on the very first update — without this, the first
871
- // save after page load would always fall back to re-mount (since
872
- // the plugin would have nothing to compare against). Vibe itself
873
- // never reads this; it's purely for the plugin spy.
874
576
  newWrapper._vibeRawSource = html;
875
- // Remount context for reactive src bindings (src="@[page.src]"):
876
- // the mounted src (no-op detection), the authored props, and the
877
- // authored slot content. Each re-mount consumes these and finalize
878
- // stashes them onto the next wrapper — self-sustaining across
879
- // arbitrarily many navigations.
880
577
  newWrapper._vibeMountedSrc = src;
881
578
  newWrapper._vibeRemountProps = props;
882
579
  newWrapper._vibeSlotContent = children;
883
- // The authored binding travels ON the wrapper (data-vibe-src, same
884
- // transport idea as data-vibe-namebind): the original tree node is
885
- // pruned when this replaceWith's removal mutation is processed, and
886
- // the replacement's reparse recaptures the binding from this
887
- // attribute — the DOM alone carries the knowledge across swaps.
888
580
  const srcBinding = el._vibeSrcBinding ?? el.getAttribute('data-vibe-src');
889
581
  if (srcBinding) newWrapper.setAttribute('data-vibe-src', srcBinding);
890
- // The key binding and its last resolved value ride along the same
891
- // way, so a later key change still finds what to compare against
892
- // on the replacement wrapper.
893
582
  const keyBinding = el._vibeKeyBinding ?? el.getAttribute('data-vibe-key');
894
583
  if (keyBinding) newWrapper.setAttribute('data-vibe-key', keyBinding);
895
584
  if (el._vibeMountedKey !== undefined) newWrapper._vibeMountedKey = el._vibeMountedKey;
896
- // Transfer iteration-prop registry ownership from the soon-to-be-
897
- // detached `<component src>` to the new wrapper. The detached element
898
- // would otherwise trigger releaseOrphanedIterationProps and free the
899
- // registry slots that the inlined template's bindings still reference,
900
- // causing every `@[window.__vibe.iterProps._pN]` to resolve to undefined
901
- // on the next hydrate.
902
585
  if (el._vibeIterPropIds) {
903
586
  newWrapper._vibeIterPropIds = el._vibeIterPropIds;
904
587
  el._vibeIterPropIds = null;
905
588
  }
906
- // The discovery marker rides on the registry path (props with iteration
907
- // aliases) AND the global-only raw-binding path (no registry slot, just
908
- // `_vibeIterPropExprs = []`). Transfer it whenever present so affected.js's
909
- // walkInlinedComponentTrees can find the wrapper and re-hydrate its
910
- // bindings on a global-state change.
911
589
  if (el.hasAttribute('data-vibe-iter-prop')) {
912
590
  newWrapper.setAttribute('data-vibe-iter-prop', '');
913
591
  el.removeAttribute('data-vibe-iter-prop');
914
592
  bumpIterPropGeneration();
915
593
  }
916
- // Transfer the original prop expressions too, so the iteration's
917
- // update path can re-evaluate them against the row's new scope and
918
- // refresh the registry slots in place — letting the inlined
919
- // component's bindings react without rebuilding the row's DOM.
920
594
  if (el._vibeIterPropExprs) {
921
595
  newWrapper._vibeIterPropExprs = el._vibeIterPropExprs;
922
596
  el._vibeIterPropExprs = null;
923
597
  }
924
- // Back-pointer from the soon-to-be-detached `<component src>` to
925
- // the new wrapper. The iteration's `instance.clonedNodes` still
926
- // references the original element; follow this link to reach the
927
- // live wrapper when refreshing registry slots / re-hydrating.
928
- // The wrapper's `_vibeIterTree` (set later by processMutations after
929
- // renderAllConditionals/Iterations populated runtime data) is what
930
- // iterate.js's update path uses to re-evaluate inlined bindings on
931
- // each row update.
932
- // The observer hydrates the inserted subtree in its NEXT batch —
933
- // until then, selectors keyed on hydrated attributes (a name-bound
934
- // <page @[page.name]> → page[pvp] rules) don't match and the
935
- // content paints unstyled. Cover the gap with the same fouc
936
- // contract pages use: hidden at insertion, revealed by the batch
937
- // that parsed and hydrated this subtree.
938
598
  newWrapper.setAttribute('vibe-fouc', '');
939
599
  el._vibeReplacedBy = newWrapper;
940
600
  if (el._vibeOutgoing) {
941
- // REMOUNT (route/key change): stage, don't swap — staging.js owns
942
- // the transition (display:none sibling now, one-paint commit in
943
- // tryReveal below).
944
601
  stageIncoming(el, newWrapper);
945
602
  } else {
946
603
  el.replaceWith(newWrapper);
947
604
  }
948
605
  debugLog(fromCache ? PHASE_FETCH_CACHED : PHASE_FETCH, src, debug);
949
606
 
950
- // The mount's own script registrations commit as one batch — the
951
- // subtree hydrates via the observer against live `$`, so the only
952
- // consumers needing a flush are whole-`$` observers elsewhere.
953
607
  notifyChanged(registeredComponentIds);
954
608
 
955
- // Build-inlined child components (compiled SPA fragments) arrive
956
- // with tagged wrapper ids and vibe-module scripts — the compiled-
957
- // document form. A fetched mount is the fourth delivery mode after
958
- // boot, conditional branches, and iteration rows: run those
959
- // scripts now so each child's component({...}) state registers
960
- // under its build-tagged id and the _cN bindings hydrate. Silent:
961
- // their registrations commit as one grouped notify when the
962
- // scripts settle — per-script flushes would re-walk the page N
963
- // times (the 26-script game fragments spent ~150ms/navigation on
964
- // exactly that). The settle promise also gates the reveal below:
965
- // a slow module import delays registration, and until it lands the
966
- // subtree's `this.`-derived bindings hydrate to junk (NaN widths —
967
- // the game's "Making potion" bar painting full/empty mid-mount).
968
609
  const scriptsSettled = Promise.resolve(
969
610
  executeCompiledComponentScriptsIn([newWrapper], { silent: true }),
970
611
  );
971
612
 
972
- // MutationObserver handles parsing and hydrating the new content.
973
- // Branch nodes are registered in the manifest by mountBranch,
974
- // so the observer can find parents even inside conditional branches.
975
- // Hydration can span multiple batches (nested fetched components,
976
- // async scripts) with paints in between — reveal only when the
977
- // wrapper's scripts have settled AND the subtree carries no raw
978
- // bindings (same predicate the page-level ready uses). The grouped
979
- // registration notify queues its correction flush BEFORE the settle
980
- // callback runs, so the recheck sees post-correction DOM in the
981
- // same microtask drain — the first painted frame is the true one.
982
- // A wrapper unmounted mid-hydration releases the hook.
983
613
  let scriptsDone = false;
984
614
  const tryReveal = () => {
985
615
  if (!newWrapper.isConnected) {
@@ -989,10 +619,6 @@ const processSingle = (el, debug) => {
989
619
  return;
990
620
  }
991
621
  if (!scriptsDone || !shouldCleanup(newWrapper)) return;
992
- // Atomic visual commit: old page out, parked styling-context
993
- // bindings applied, new page revealed — one synchronous block,
994
- // one paint. Until this moment the outgoing page was still the
995
- // one on screen, fully styled.
996
622
  commitStaged(newWrapper);
997
623
  newWrapper.removeAttribute('vibe-fouc');
998
624
  unfouc();
@@ -1003,31 +629,19 @@ const processSingle = (el, debug) => {
1003
629
  tryReveal();
1004
630
  });
1005
631
  } else {
1006
- // Element was detached before finalize ran (conditional unmounted
1007
- // during fetch, parent removed, etc). Release any state component()
1008
- // calls registered — otherwise it leaks on `$` forever.
1009
632
  releaseOutgoing(el);
1010
633
  releaseRegisteredIds();
1011
634
  }
1012
635
  };
1013
636
 
1014
- // If any scripts had async imports, wait for them before finalizing.
1015
- // Otherwise finalize synchronously (preserves original boot timing).
1016
637
  if (asyncTasks.length > 0) {
1017
638
  return Promise.all(asyncTasks).then(finalize);
1018
639
  }
1019
640
  finalize();
1020
641
  })
1021
642
  .catch((error) => {
1022
- // Clean up pending fetch tracker — ownership-guarded (see finalize)
1023
643
  if (pendingFetches.get(el) === controller) pendingFetches.delete(el);
1024
644
 
1025
- // Aborted fetch: a removal-abort (wrapper left the DOM mid-remount)
1026
- // has no commit left to release its outgoing freeze — free the root
1027
- // here or the detached page subtree stays pinned in
1028
- // activeOutgoingRoots for the session. A supersede-abort (newer
1029
- // remount on the same, still-connected wrapper) keeps it: the new
1030
- // fetch owns the freeze.
1031
645
  if (error.name === 'AbortError') {
1032
646
  if (!el.isConnected) releaseOutgoing(el);
1033
647
  return;
@@ -1050,11 +664,8 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
1050
664
  return;
1051
665
  }
1052
666
 
1053
- // Only process top-level components — skip those nested inside other
1054
- // unprocessed component[src] elements (they're slot content that will
1055
- // be revealed when the parent component finalizes).
1056
667
  const topLevel = Array.from(allComponents).filter(
1057
- (el) => !isNestedInUnprocessedComponent(el, rootElement)
668
+ (el) => !hasUnresolvedSrc(el) && !isNestedInUnprocessedComponent(el, rootElement)
1058
669
  );
1059
670
 
1060
671
  if (topLevel.length === 0) {
@@ -1062,10 +673,5 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
1062
673
  return;
1063
674
  }
1064
675
 
1065
- // Fetch and process all top-level components in parallel.
1066
- // Nested components (inside finalized content) are discovered and
1067
- // processed by the MutationObserver → processComponent chain.
1068
- // onComplete is handled by checkCleanup (which fires when no
1069
- // component[src] elements remain).
1070
676
  topLevel.forEach((el) => processSingle(el, debug));
1071
677
  };