@ape-egg/vibe 4.0.1 → 4.1.3

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.
@@ -1,5 +1,6 @@
1
1
  import { debugLog } from './debug.js';
2
2
  import { shouldCleanup } from './cleanup.js';
3
+ import { isInert } from './inert.js';
3
4
  import {
4
5
  PHASE_FETCH,
5
6
  PHASE_FETCH_CACHED,
@@ -27,60 +28,29 @@ import {
27
28
 
28
29
  export { activeOutgoingRoots, isComponentWrapper };
29
30
 
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
31
  let componentCounter = 1000000;
37
32
 
38
- /**
39
- * Generate unique component ID
40
- * Uses deterministic counter: _c0, _c1, _c2, etc.
41
- */
42
33
  export const generateComponentId = () => {
43
34
  return `_c${componentCounter++}`;
44
35
  };
45
36
 
46
- // Helper to escape regex special characters
47
37
  const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
48
38
 
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
39
  export const trackComponentOwnership = () => {};
54
40
 
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
41
  export const releaseOrphanedComponentState = (collectedIds) => {
62
42
  if (!collectedIds || collectedIds.size === 0) return;
63
43
  for (const id of collectedIds) {
64
44
  if (document.querySelector(`[data-vibe-component-id="${id}"]`)) continue;
65
45
  delete window.__vibe?.components?.[id];
66
- // CLEANUP OF CURRENT STATE
67
46
  delete window.$[id];
68
47
  runComponentCleanups(id);
69
48
  }
70
49
  };
71
50
 
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
51
  export const runComponentCleanups = (componentId) => {
78
52
  const cleanups = window.__vibe?.cleanups?.[componentId];
79
53
  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
54
  delete window.__vibe.cleanups[componentId];
85
55
  for (let i = 0; i < cleanups.length; i++) {
86
56
  try {
@@ -91,11 +61,6 @@ export const runComponentCleanups = (componentId) => {
91
61
  }
92
62
  };
93
63
 
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
64
  const createScopedDollar = (componentId) => {
100
65
  const dollar = window.$;
101
66
  if (!dollar) return dollar;
@@ -103,14 +68,6 @@ const createScopedDollar = (componentId) => {
103
68
  get(target, prop, receiver) {
104
69
  if (prop === 'on') {
105
70
  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
71
  if (event === 'unmount') {
115
72
  const cleanupsReg = ((window.__vibe ??= {}).cleanups ??= {});
116
73
  const slot = cleanupsReg[componentId] || (cleanupsReg[componentId] = []);
@@ -135,8 +92,6 @@ const createScopedDollar = (componentId) => {
135
92
  });
136
93
  };
137
94
 
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
95
  export const collectComponentIds = (node, into = new Set()) => {
141
96
  if (!node) return into;
142
97
  if (node.nodeType === 1) {
@@ -149,25 +104,15 @@ export const collectComponentIds = (node, into = new Set()) => {
149
104
  return into;
150
105
  };
151
106
 
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
107
  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
108
  let content = rawContent.replace(
161
109
  /import\s+component\s+from\s+['"][^'"]+['"];?\s*/g,
162
110
  ''
163
111
  );
164
112
 
165
- // Check for remaining imports that need rewriting
166
113
  const hasImports = /import\s/.test(content);
167
114
 
168
115
  if (hasImports) {
169
- // Rewrite remaining imports to dynamic await import()
170
- // Order matters: combined → default → named → namespace → side-effect (most specific first)
171
116
  content = content.replace(
172
117
  /import\s+(\w+)\s*,\s*\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
173
118
  'const __m_$1 = await import($3); const $1 = __m_$1.default; const {$2} = __m_$1;'
@@ -193,24 +138,6 @@ const transformScriptContent = (rawContent) => {
193
138
  return { content, hasImports };
194
139
  };
195
140
 
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
141
  export const executeCompiledComponentScripts = () => {
215
142
  const scripts = [];
216
143
  collectMountedModuleScripts(document.body ? [document.body] : [], scripts);
@@ -219,21 +146,6 @@ export const executeCompiledComponentScripts = () => {
219
146
  return runVibeModuleScripts(scripts);
220
147
  };
221
148
 
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
149
  export const executeCompiledComponentScriptsIn = (nodes, { silent = false } = {}) => {
238
150
  const scripts = [];
239
151
  collectMountedModuleScripts(nodes, scripts);
@@ -242,16 +154,6 @@ export const executeCompiledComponentScriptsIn = (nodes, { silent = false } = {}
242
154
  return runVibeModuleScripts(scripts, silent);
243
155
  };
244
156
 
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
157
  const collectMountedModuleScripts = (nodes, out) => {
256
158
  let depth = 0;
257
159
  for (const node of nodes) {
@@ -267,8 +169,6 @@ const collectMountedModuleScripts = (nodes, out) => {
267
169
  }
268
170
  };
269
171
 
270
- // Build-time tagging already assigned _cN ids to wrappers; advance the runtime
271
- // counter past them so freshly generated ids never collide.
272
172
  const advanceComponentCounterPastIds = (root) => {
273
173
  root.querySelectorAll('[data-vibe-component-id]').forEach((el) => {
274
174
  const m = el.getAttribute('data-vibe-component-id')?.match(/^_c(\d+)$/);
@@ -276,11 +176,6 @@ const advanceComponentCounterPastIds = (root) => {
276
176
  });
277
177
  };
278
178
 
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
179
  const registerComponentState = (componentId, state, silent) => {
285
180
  ((window.__vibe ??= {}).components ??= {})[componentId] = state;
286
181
  if (window.$) {
@@ -289,18 +184,6 @@ const registerComponentState = (componentId, state, silent) => {
289
184
  }
290
185
  };
291
186
 
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
187
  let scriptChain = null;
305
188
 
306
189
  const enqueueScriptExecution = (run) => {
@@ -321,10 +204,6 @@ const enqueueScriptExecution = (run) => {
321
204
  return result;
322
205
  };
323
206
 
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
207
  const executeScriptUnit = (componentId, content, hasImports, scopedDollar, componentFn) => () => {
329
208
  runComponentCleanups(componentId);
330
209
  try {
@@ -340,13 +219,6 @@ const executeScriptUnit = (componentId, content, hasImports, scopedDollar, compo
340
219
  }
341
220
  };
342
221
 
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
222
  const wrapperSetupScript = (wrapper) => {
351
223
  for (const s of wrapper.querySelectorAll('script[type="vibe-module"]')) {
352
224
  if (s.closest('[data-vibe-component-id]') === wrapper) return s;
@@ -359,14 +231,8 @@ const runVibeModuleScripts = (scripts, silent = false) => {
359
231
  const registeredIds = [];
360
232
 
361
233
  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
234
  if (script.__vibeExecuted) continue;
368
235
 
369
- // Parity with the fetch path: dehydrated components never execute
370
236
  if (script.closest(`[${DEHYDRATE_CLASS_OR_ATTR}], .${DEHYDRATE_CLASS_OR_ATTR}`)) continue;
371
237
 
372
238
  const rawContent = script.textContent?.trim() || '';
@@ -393,8 +259,6 @@ const runVibeModuleScripts = (scripts, silent = false) => {
393
259
  if (task) asyncTasks.push(task);
394
260
  }
395
261
 
396
- // Silent mode: one grouped commit once every script has registered, so
397
- // whole-`$` observers see all fresh keys with a single flush.
398
262
  if (silent) {
399
263
  if (asyncTasks.length) {
400
264
  return Promise.all(asyncTasks).then(() => notifyChanged(registeredIds));
@@ -406,20 +270,12 @@ const runVibeModuleScripts = (scripts, silent = false) => {
406
270
  return asyncTasks.length ? Promise.all(asyncTasks) : null;
407
271
  };
408
272
 
409
- // Track pending fetches to cancel them if element is removed
410
- const pendingFetches = new WeakMap(); // element → AbortController
273
+ const pendingFetches = new WeakMap();
411
274
 
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
275
  let inertDocument;
419
276
  const createDetached = (tagName) =>
420
277
  (inertDocument ??= document.implementation.createHTMLDocument('')).createElement(tagName);
421
278
 
422
- // Cancel a pending fetch for a component element
423
279
  export const abortComponentFetch = (element) => {
424
280
  const controller = pendingFetches.get(element);
425
281
  if (controller) {
@@ -428,73 +284,34 @@ export const abortComponentFetch = (element) => {
428
284
  }
429
285
  };
430
286
 
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
287
  export const liveComponentWrapper = liveNode;
435
288
 
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
289
  export const remountComponent = (el, src, debug = false) => {
446
290
  const wasFetching = pendingFetches.has(el);
447
291
  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
292
  const current = wasFetching
452
293
  ? el.getAttribute('src')
453
294
  : (el._vibeMountedSrc ?? el.getAttribute('src'));
454
295
  if (src === current) return;
455
296
  abortComponentFetch(el);
456
297
  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
298
  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
299
  markOutgoing(el);
470
300
  processSingle(el, debug);
471
301
  };
472
302
 
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
303
  export const forceRemount = (el, debug = false) => {
479
304
  if (pendingFetches.has(el) || el._vibeMountedSrc === undefined) return;
480
305
  el.setAttribute('src', el._vibeMountedSrc);
481
- // Same outgoing freeze as remountComponent — a keyed remount replaces the
482
- // mounted content just the same.
483
306
  markOutgoing(el);
484
307
  processSingle(el, debug);
485
308
  };
486
309
 
487
- // A src still carrying an @[…] binding is not a URL — it's source material
488
- // (an iteration or branch template the renderer hasn't consumed yet, or an
489
- // outlet whose scope owner hasn't resolved it). Fetching it verbatim is never
490
- // right: initializeBlock substitutes loop-scoped srcs, hydration remounts
491
- // reactive outlets, and this pass only mounts real URLs.
492
310
  const hasUnresolvedSrc = (el) => {
493
311
  BINDING_REGEX.lastIndex = 0;
494
312
  return BINDING_REGEX.test(el.getAttribute('src'));
495
313
  };
496
314
 
497
- // Check if an element is nested inside another unprocessed component[src]
498
315
  const isNestedInUnprocessedComponent = (el, rootElement) => {
499
316
  let parent = el.parentElement;
500
317
  while (parent && parent !== rootElement) {
@@ -509,17 +326,6 @@ const isNestedInUnprocessedComponent = (el, rootElement) => {
509
326
  return false;
510
327
  };
511
328
 
512
- // Rewrite `@[this.x...]` and `$.this.x...` inside element's text/attrs to use
513
- // componentId. Shared between script-execution path and pure-render path.
514
- //
515
- // Within `@[...]` bindings we rewrite every `this.X` reference (preserving
516
- // any trailing `.Y.Z` chain), so `@[this.user.name]` and `@[this.x + 1]` both
517
- // resolve correctly. Outside bindings — i.e. event handler attribute bodies
518
- // like `onclick="$.this.mode = 'edit'"` — only `$.this.X` writes are
519
- // rewritten; bare `this.X` in event handlers is handled later by parse.js,
520
- // which lowers it to `$this(this).X` — resolved at fire time by
521
- // this-scope.js (declared state keys hit the bucket, everything else stays
522
- // the native element).
523
329
  const rewriteBindingsInString = (str, componentId) =>
524
330
  str.replace(BINDING_REGEX, (match, expr) => {
525
331
  const rewritten = expr.replace(THIS_PROP_REGEX, `${componentId}.$1`);
@@ -547,20 +353,12 @@ const rewriteThisBindings = (element, componentId) => {
547
353
  });
548
354
  };
549
355
 
550
- // Tag siblings of a <script> with componentId and rewrite `this.` bindings.
551
- // Runs BEFORE script.remove() so nextElementSibling is valid. Shared by
552
- // processSingle (executing path) and renderComponentTemplate (pure path).
553
356
  const tagScriptSiblings = (script, componentId) => {
554
357
  let sibling = script.nextElementSibling;
555
358
  while (sibling) {
556
359
  if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
557
360
  break;
558
361
  }
559
- // A sibling already carrying an id is a build-tagged compiled wrapper
560
- // that owns its scope — its `this.` bindings were rewritten to that id
561
- // at compile time. Overwriting it re-scopes the wrapper to THIS script's
562
- // id and orphans every `@[_cN.x]` binding inside it (the game's
563
- // top-level create-teamplay modal losing its state).
564
362
  if (!sibling.hasAttribute('data-vibe-component-id')) {
565
363
  sibling.setAttribute('data-vibe-component-id', componentId);
566
364
  rewriteThisBindings(sibling, componentId);
@@ -569,21 +367,12 @@ const tagScriptSiblings = (script, componentId) => {
569
367
  }
570
368
  };
571
369
 
572
- // Substitute props + inline slot content into a pre-processed temp container.
573
- // Returns the final processed HTML string. Shared by processSingle.finalize()
574
- // (runtime mount) and renderComponentTemplate (surgical HMR).
575
370
  const renderPropsAndSlot = (temp, props, slotHtml) => {
576
371
  let transformedHtml = temp.innerHTML;
577
372
 
578
373
  const DIRECTIVE_COMMENT_REGEX = /<!--\s*(if|else if|each)\s+([^]*?)\s*-->/g;
579
374
  const EVENT_ATTR_REGEX = /\bon(\w+)="([^"]*)"/g;
580
375
 
581
- // HTML's parser lowercases attribute names, so a consumer-written
582
- // `<component dndDisabled>` arrives here with propName `dnddisabled` while
583
- // the component template author wrote `dndDisabled`. Match identifiers and
584
- // bindings case-insensitively so both sides line up. Word-boundary
585
- // lookbehind/lookahead still hold (they're case-agnostic), so
586
- // `dnddisabled` won't bleed into `dndDisabledAlt`.
587
376
  Object.entries(props).forEach(([propName, propValue]) => {
588
377
  const bindingMatch = propValue.match(/^@\[(.+)\]$/);
589
378
  const exactPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'gi');
@@ -592,11 +381,6 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
592
381
  'gi'
593
382
  );
594
383
 
595
- // Rewrite the identifier only outside string literals. A prop/alias that also
596
- // appears inside a quoted string — e.g. the selector in `closest('brawler-slot')`
597
- // when the prop is `slot` — must be left intact. Split on string literals
598
- // (single/double/backtick, honouring escapes) and substitute only the code spans
599
- // between them. Template `${…}` interpolation counts as part of the literal here.
600
384
  const stringLiteral = /(['"`])(?:\\.|(?!\1)[^\\])*\1/g;
601
385
  const substituteInExpr = (expr, replacement) => {
602
386
  let out = '';
@@ -627,11 +411,6 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
627
411
  );
628
412
  const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'gi');
629
413
  transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
630
- // Substitute the bare prop identifier too (not just the `$.prop` form),
631
- // mirroring the @[...] binding pass above — so `onclick="pick(item)"`
632
- // resolves the live prop, not a global that throws ReferenceError. A
633
- // loop-alias path (`row.sig`) becomes `(row.sig)` here; parse.js then
634
- // rewrites the alias to `$scope(this,'row')`.
635
414
  const rewritten = substituteInExpr(body.replace(stateRegex, `$.${path}`), `(${path})`);
636
415
  return rewritten === body ? match : `on${evName}="${rewritten}"`;
637
416
  });
@@ -658,8 +437,6 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
658
437
  );
659
438
  const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'gi');
660
439
  transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
661
- // Same bare-identifier substitution for a literal prop: `onclick="set(limit)"`
662
- // with `limit="5"` becomes `set(5)`.
663
440
  const rewritten = substituteInExpr(body.replace(stateRegex, `$.${literal}`), literal);
664
441
  return rewritten === body ? match : `on${evName}="${rewritten}"`;
665
442
  });
@@ -681,12 +458,6 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
681
458
  return transformedHtml;
682
459
  };
683
460
 
684
- // Pure-render path for surgical HMR. Takes a component's raw template HTML
685
- // plus the live instance's props, slot, and existing componentIds. Returns
686
- // the processed HTML string that $.reconcile can diff against the live
687
- // wrapper's children. Scripts are NOT executed — callers are responsible
688
- // for deciding whether to preserve the existing state (reuse componentIds)
689
- // or trigger a full re-mount (new componentIds).
690
461
  export const renderComponentTemplate = (rawHtml, props = {}, slotHtml = '', options = {}) => {
691
462
  const { componentIds = [] } = options;
692
463
  const temp = createDetached('div');
@@ -703,31 +474,19 @@ export const renderComponentTemplate = (rawHtml, props = {}, slotHtml = '', opti
703
474
  return renderPropsAndSlot(temp, props, slotHtml);
704
475
  };
705
476
 
706
- // Process a single component element: fetch HTML, execute scripts, replace DOM
707
477
  const processSingle = (el, debug) => {
708
- // Skip dehydrated components
709
478
  if (el.hasAttribute(DEHYDRATE_CLASS_OR_ATTR) || el.classList.contains(DEHYDRATE_CLASS_OR_ATTR)) {
710
479
  return Promise.resolve();
711
480
  }
712
481
 
713
- // Skip if fetch already in flight for this element
714
482
  if (pendingFetches.has(el)) return Promise.resolve();
715
483
 
716
484
  const src = el.getAttribute('src');
717
485
 
718
- // Use pre-hydration slot content if available (saved by index.js before
719
- // hydration ran, or re-stashed by finalize for reactive-src re-mounts),
720
- // otherwise fall back to current innerHTML (e.g. runtime-only usage without
721
- // boot). Kept on the element — a re-mount consumes the same authored slot.
722
486
  const children = (el._vibeSlotContent !== undefined ? el._vibeSlotContent : el.innerHTML).trim();
723
- // A re-mounted wrapper carries no prop attributes (finalize stripped them) —
724
- // its authored props ride the remount context stashed at the previous mount.
725
487
  let props = el._vibeRemountProps;
726
488
  if (!props) {
727
489
  props = {};
728
- // `src` and `key` are the wrapper's own contract (what to mount / when to
729
- // remount), and data-vibe-* attributes are runtime transport — none of
730
- // them are authored props for the component.
731
490
  Array.from(el.attributes).forEach((attr) => {
732
491
  if (attr.name !== 'src' && attr.name !== 'key' && !attr.name.startsWith('data-vibe-')) {
733
492
  props[attr.name] = attr.value;
@@ -735,47 +494,24 @@ const processSingle = (el, debug) => {
735
494
  });
736
495
  }
737
496
 
738
- // Capture cache state before the fetch so the debug layer can tell a real
739
- // network fetch from a runtime-cache hit (the call below would make them
740
- // indistinguishable — both just resolve a promise).
741
497
  const fromCache = isComponentCached(src);
742
498
 
743
- // Create AbortController to cancel fetch if element is removed
744
499
  const controller = new AbortController();
745
500
  pendingFetches.set(el, controller);
746
501
 
747
502
  return fetchComponentTemplate(src, controller.signal)
748
503
  .then((html) => {
749
- // Superseded before the template arrived: the cache path resolves
750
- // regardless of the abort signal (aborting a cache hit is meaningless
751
- // network-wise), so the supersede check lives here. A newer remount
752
- // owns the wrapper — running this mount's scripts or finalize would
753
- // land the stale fragment and discard the new one.
754
504
  if (controller.signal.aborted) return;
755
505
 
756
- // Parse HTML in temporary container to process component scripts
757
506
  const temp = createDetached('div');
758
507
  temp.innerHTML = html;
759
508
 
760
- // Process any <script type="module"> elements
761
509
  const moduleScripts = temp.querySelectorAll('script[type="module"]');
762
510
 
763
- // Process each script — collect async tasks if any have imports
764
511
  const asyncTasks = [];
765
512
 
766
- // Track every componentId registered during this fetch. If the host
767
- // element is detached before finalize runs (e.g. a conditional unmounted
768
- // mid-fetch, or the user navigated away), we release these state
769
- // buckets — otherwise component({...}) leaks state to `$` for DOM that
770
- // never reaches the document.
771
513
  const registeredComponentIds = [];
772
514
 
773
- // First componentId encountered — applied to the wrapper itself so
774
- // directives living between top-level sibling roots (e.g. a comment
775
- // marker for `<!-- if this.X -->`) can resolve component scope via
776
- // closest('[data-vibe-component-id]'). Without this, multi-root
777
- // templates have scope-less wrappers and top-level `this.` references
778
- // fall through to global state.
779
515
  let firstComponentId = null;
780
516
 
781
517
  for (const script of moduleScripts) {
@@ -784,54 +520,29 @@ const processSingle = (el, debug) => {
784
520
 
785
521
  const { content: scriptContent, hasImports } = transformScriptContent(rawContent);
786
522
 
787
- // Reuse component ID from HMR if available, otherwise generate new
788
523
  const reuseIds = el._vibeReuseComponentIds;
789
524
  const componentId = reuseIds && reuseIds.length ? reuseIds.shift() : generateComponentId();
790
525
 
791
526
  if (firstComponentId === null) firstComponentId = componentId;
792
527
 
793
- // Provide a component() function that registers state for this component.
794
- // Sibling tagging is handled below (before script.remove()) so it works
795
- // for both sync and async scripts.
796
528
  const componentFn = (state) => {
797
- // Fetched-mount registration is silent: the fetched subtree's
798
- // hydration runs after replaceWith against live `$`, so a fresh id
799
- // needs no flush (see registerComponentState / $.register).
800
529
  registerComponentState(componentId, state, true);
801
530
  if (!registeredComponentIds.includes(componentId)) {
802
531
  registeredComponentIds.push(componentId);
803
532
  }
804
- // Return the id so consumers can reach their reactive state via
805
- // `$[id]` — matches the public component.js contract. Without this,
806
- // `const id = component(state)` is undefined for src-fetched
807
- // components and `$[id]` silently resolves to nothing.
808
533
  return componentId;
809
534
  };
810
535
 
811
- // Execute through the global script chain (see enqueueScriptExecution):
812
- // cleanups for a reused componentId (HMR remount) tear down the prior
813
- // evaluation's listeners right before the fresh run, the scoped `$`
814
- // tracks this script's `.on(...)` registrations, and document-order
815
- // execution holds even against scripts from other batches (a fetched
816
- // component's script never runs while the shell's boot import is
817
- // still in flight).
818
536
  const task = enqueueScriptExecution(
819
537
  executeScriptUnit(componentId, scriptContent, hasImports, createScopedDollar(componentId), componentFn),
820
538
  );
821
539
  if (task) asyncTasks.push(task);
822
540
 
823
- // Tag siblings + rewrite this. bindings using shared helper. Runs
824
- // BEFORE script.remove() so nextElementSibling is valid.
825
541
  tagScriptSiblings(script, componentId);
826
542
 
827
- // Remove script from temp (we executed it manually)
828
543
  script.remove();
829
544
  }
830
545
 
831
- // Evict everything this mount's scripts registered — state buckets AND
832
- // their $.on listeners/unmount side effects. The scripts already RAN
833
- // (execution precedes finalize), so skipping the cleanups here leaks
834
- // live global listeners for DOM that never mounts.
835
546
  const releaseRegisteredIds = () => {
836
547
  for (const id of registeredComponentIds) {
837
548
  delete window.__vibe?.components?.[id];
@@ -840,27 +551,17 @@ const processSingle = (el, debug) => {
840
551
  }
841
552
  };
842
553
 
843
- // Finalize: props, slots, DOM replacement
844
554
  const finalize = () => {
845
- // Superseded DURING script execution (an async import held this mount
846
- // while a newer remount aborted it): the newer fetch owns the wrapper.
847
555
  if (controller.signal.aborted) {
848
556
  releaseRegisteredIds();
849
557
  return;
850
558
  }
851
559
 
852
- // Delegate prop substitution + slot inlining to shared helper.
853
560
  const transformedHtml = renderPropsAndSlot(temp, props, children);
854
561
 
855
- // Clean up pending fetch tracker — only if this fetch still owns the
856
- // slot (a reactive-src re-mount may have aborted us and registered a
857
- // newer controller for the same element).
858
562
  if (pendingFetches.get(el) === controller) pendingFetches.delete(el);
859
563
 
860
- // Replace with clean component wrapper (no src, no props)
861
- // Check if element still has a parent (might have been removed during fetch)
862
564
  if (el.parentNode) {
863
- // Create clean wrapper element (preserve tag type: component or div.component)
864
565
  const newWrapper =
865
566
  el.tagName === 'DIV' ? createDetached('div') : createDetached('component');
866
567
 
@@ -869,127 +570,47 @@ const processSingle = (el, debug) => {
869
570
  }
870
571
 
871
572
  newWrapper.innerHTML = transformedHtml;
872
- // Park binding-valued src while the wrapper still lives in the inert
873
- // document — adoption into the live document is what starts image
874
- // loads, so this is the last moment a raw `@[...]` src is harmless.
875
573
  parkFetchableSrc(newWrapper);
876
574
  if (firstComponentId !== null) {
877
575
  newWrapper.setAttribute('data-vibe-component-id', firstComponentId);
878
576
  }
879
- // Stash the raw source so the HMR plugin can establish a baseline
880
- // script hash on the very first update — without this, the first
881
- // save after page load would always fall back to re-mount (since
882
- // the plugin would have nothing to compare against). Vibe itself
883
- // never reads this; it's purely for the plugin spy.
884
577
  newWrapper._vibeRawSource = html;
885
- // Remount context for reactive src bindings (src="@[page.src]"):
886
- // the mounted src (no-op detection), the authored props, and the
887
- // authored slot content. Each re-mount consumes these and finalize
888
- // stashes them onto the next wrapper — self-sustaining across
889
- // arbitrarily many navigations.
890
578
  newWrapper._vibeMountedSrc = src;
891
579
  newWrapper._vibeRemountProps = props;
892
580
  newWrapper._vibeSlotContent = children;
893
- // The authored binding travels ON the wrapper (data-vibe-src, same
894
- // transport idea as data-vibe-namebind): the original tree node is
895
- // pruned when this replaceWith's removal mutation is processed, and
896
- // the replacement's reparse recaptures the binding from this
897
- // attribute — the DOM alone carries the knowledge across swaps.
898
581
  const srcBinding = el._vibeSrcBinding ?? el.getAttribute('data-vibe-src');
899
582
  if (srcBinding) newWrapper.setAttribute('data-vibe-src', srcBinding);
900
- // The key binding and its last resolved value ride along the same
901
- // way, so a later key change still finds what to compare against
902
- // on the replacement wrapper.
903
583
  const keyBinding = el._vibeKeyBinding ?? el.getAttribute('data-vibe-key');
904
584
  if (keyBinding) newWrapper.setAttribute('data-vibe-key', keyBinding);
905
585
  if (el._vibeMountedKey !== undefined) newWrapper._vibeMountedKey = el._vibeMountedKey;
906
- // Transfer iteration-prop registry ownership from the soon-to-be-
907
- // detached `<component src>` to the new wrapper. The detached element
908
- // would otherwise trigger releaseOrphanedIterationProps and free the
909
- // registry slots that the inlined template's bindings still reference,
910
- // causing every `@[window.__vibe.iterProps._pN]` to resolve to undefined
911
- // on the next hydrate.
912
586
  if (el._vibeIterPropIds) {
913
587
  newWrapper._vibeIterPropIds = el._vibeIterPropIds;
914
588
  el._vibeIterPropIds = null;
915
589
  }
916
- // The discovery marker rides on the registry path (props with iteration
917
- // aliases) AND the global-only raw-binding path (no registry slot, just
918
- // `_vibeIterPropExprs = []`). Transfer it whenever present so affected.js's
919
- // walkInlinedComponentTrees can find the wrapper and re-hydrate its
920
- // bindings on a global-state change.
921
590
  if (el.hasAttribute('data-vibe-iter-prop')) {
922
591
  newWrapper.setAttribute('data-vibe-iter-prop', '');
923
592
  el.removeAttribute('data-vibe-iter-prop');
924
593
  bumpIterPropGeneration();
925
594
  }
926
- // Transfer the original prop expressions too, so the iteration's
927
- // update path can re-evaluate them against the row's new scope and
928
- // refresh the registry slots in place — letting the inlined
929
- // component's bindings react without rebuilding the row's DOM.
930
595
  if (el._vibeIterPropExprs) {
931
596
  newWrapper._vibeIterPropExprs = el._vibeIterPropExprs;
932
597
  el._vibeIterPropExprs = null;
933
598
  }
934
- // Back-pointer from the soon-to-be-detached `<component src>` to
935
- // the new wrapper. The iteration's `instance.clonedNodes` still
936
- // references the original element; follow this link to reach the
937
- // live wrapper when refreshing registry slots / re-hydrating.
938
- // The wrapper's `_vibeIterTree` (set later by processMutations after
939
- // renderAllConditionals/Iterations populated runtime data) is what
940
- // iterate.js's update path uses to re-evaluate inlined bindings on
941
- // each row update.
942
- // The observer hydrates the inserted subtree in its NEXT batch —
943
- // until then, selectors keyed on hydrated attributes (a name-bound
944
- // <page @[page.name]> → page[pvp] rules) don't match and the
945
- // content paints unstyled. Cover the gap with the same fouc
946
- // contract pages use: hidden at insertion, revealed by the batch
947
- // that parsed and hydrated this subtree.
948
599
  newWrapper.setAttribute('vibe-fouc', '');
949
600
  el._vibeReplacedBy = newWrapper;
950
601
  if (el._vibeOutgoing) {
951
- // REMOUNT (route/key change): stage, don't swap — staging.js owns
952
- // the transition (display:none sibling now, one-paint commit in
953
- // tryReveal below).
954
602
  stageIncoming(el, newWrapper);
955
603
  } else {
956
604
  el.replaceWith(newWrapper);
957
605
  }
958
606
  debugLog(fromCache ? PHASE_FETCH_CACHED : PHASE_FETCH, src, debug);
959
607
 
960
- // The mount's own script registrations commit as one batch — the
961
- // subtree hydrates via the observer against live `$`, so the only
962
- // consumers needing a flush are whole-`$` observers elsewhere.
963
608
  notifyChanged(registeredComponentIds);
964
609
 
965
- // Build-inlined child components (compiled SPA fragments) arrive
966
- // with tagged wrapper ids and vibe-module scripts — the compiled-
967
- // document form. A fetched mount is the fourth delivery mode after
968
- // boot, conditional branches, and iteration rows: run those
969
- // scripts now so each child's component({...}) state registers
970
- // under its build-tagged id and the _cN bindings hydrate. Silent:
971
- // their registrations commit as one grouped notify when the
972
- // scripts settle — per-script flushes would re-walk the page N
973
- // times (the 26-script game fragments spent ~150ms/navigation on
974
- // exactly that). The settle promise also gates the reveal below:
975
- // a slow module import delays registration, and until it lands the
976
- // subtree's `this.`-derived bindings hydrate to junk (NaN widths —
977
- // the game's "Making potion" bar painting full/empty mid-mount).
978
610
  const scriptsSettled = Promise.resolve(
979
611
  executeCompiledComponentScriptsIn([newWrapper], { silent: true }),
980
612
  );
981
613
 
982
- // MutationObserver handles parsing and hydrating the new content.
983
- // Branch nodes are registered in the manifest by mountBranch,
984
- // so the observer can find parents even inside conditional branches.
985
- // Hydration can span multiple batches (nested fetched components,
986
- // async scripts) with paints in between — reveal only when the
987
- // wrapper's scripts have settled AND the subtree carries no raw
988
- // bindings (same predicate the page-level ready uses). The grouped
989
- // registration notify queues its correction flush BEFORE the settle
990
- // callback runs, so the recheck sees post-correction DOM in the
991
- // same microtask drain — the first painted frame is the true one.
992
- // A wrapper unmounted mid-hydration releases the hook.
993
614
  let scriptsDone = false;
994
615
  const tryReveal = () => {
995
616
  if (!newWrapper.isConnected) {
@@ -999,10 +620,6 @@ const processSingle = (el, debug) => {
999
620
  return;
1000
621
  }
1001
622
  if (!scriptsDone || !shouldCleanup(newWrapper)) return;
1002
- // Atomic visual commit: old page out, parked styling-context
1003
- // bindings applied, new page revealed — one synchronous block,
1004
- // one paint. Until this moment the outgoing page was still the
1005
- // one on screen, fully styled.
1006
623
  commitStaged(newWrapper);
1007
624
  newWrapper.removeAttribute('vibe-fouc');
1008
625
  unfouc();
@@ -1013,31 +630,19 @@ const processSingle = (el, debug) => {
1013
630
  tryReveal();
1014
631
  });
1015
632
  } else {
1016
- // Element was detached before finalize ran (conditional unmounted
1017
- // during fetch, parent removed, etc). Release any state component()
1018
- // calls registered — otherwise it leaks on `$` forever.
1019
633
  releaseOutgoing(el);
1020
634
  releaseRegisteredIds();
1021
635
  }
1022
636
  };
1023
637
 
1024
- // If any scripts had async imports, wait for them before finalizing.
1025
- // Otherwise finalize synchronously (preserves original boot timing).
1026
638
  if (asyncTasks.length > 0) {
1027
639
  return Promise.all(asyncTasks).then(finalize);
1028
640
  }
1029
641
  finalize();
1030
642
  })
1031
643
  .catch((error) => {
1032
- // Clean up pending fetch tracker — ownership-guarded (see finalize)
1033
644
  if (pendingFetches.get(el) === controller) pendingFetches.delete(el);
1034
645
 
1035
- // Aborted fetch: a removal-abort (wrapper left the DOM mid-remount)
1036
- // has no commit left to release its outgoing freeze — free the root
1037
- // here or the detached page subtree stays pinned in
1038
- // activeOutgoingRoots for the session. A supersede-abort (newer
1039
- // remount on the same, still-connected wrapper) keeps it: the new
1040
- // fetch owns the freeze.
1041
646
  if (error.name === 'AbortError') {
1042
647
  if (!el.isConnected) releaseOutgoing(el);
1043
648
  return;
@@ -1060,12 +665,11 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
1060
665
  return;
1061
666
  }
1062
667
 
1063
- // Only process top-level components with a fetchable src — skip unresolved
1064
- // binding srcs (never fetchable), and skip those nested inside other
1065
- // unprocessed component[src] elements (they're slot content that will
1066
- // be revealed when the parent component finalizes).
1067
668
  const topLevel = Array.from(allComponents).filter(
1068
- (el) => !hasUnresolvedSrc(el) && !isNestedInUnprocessedComponent(el, rootElement)
669
+ (el) =>
670
+ !hasUnresolvedSrc(el) &&
671
+ !isInert(el, rootElement) &&
672
+ !isNestedInUnprocessedComponent(el, rootElement)
1069
673
  );
1070
674
 
1071
675
  if (topLevel.length === 0) {
@@ -1073,10 +677,5 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
1073
677
  return;
1074
678
  }
1075
679
 
1076
- // Fetch and process all top-level components in parallel.
1077
- // Nested components (inside finalized content) are discovered and
1078
- // processed by the MutationObserver → processComponent chain.
1079
- // onComplete is handled by checkCleanup (which fires when no
1080
- // component[src] elements remain).
1081
680
  topLevel.forEach((el) => processSingle(el, debug));
1082
681
  };