@ape-egg/vibe 4.1.3 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vibe
2
2
 
3
- **Version 4.1.3** — Runtime-first reactivity for plain HTML. Drop a script tag into any page and get reactive bindings, control flow, and URL-loaded components — no build step required. Compile later if you want; the compiler is a separate, optional package (`@ape-egg/vibe-compiler`).
3
+ **Version 4.2.0** — Runtime-first reactivity for plain HTML. Drop a script tag into any page and get reactive bindings, control flow, and URL-loaded components — no build step required. Compile later if you want; the compiler is a separate, optional package (`@ape-egg/vibe-compiler`).
4
4
 
5
5
  ## Security model & CSP
6
6
 
@@ -197,7 +197,7 @@ For state scoped to a single component, import `component` from `@ape-egg/vibe/c
197
197
  How it works:
198
198
 
199
199
  1. `component({...})` generates a unique id (e.g. `_c0`, `_c1`) and registers the state at `$[id]`
200
- 2. The `<script>` and every following sibling is tagged with `data-vibe-component-id="<id>"`
200
+ 2. The `<script>` and every following sibling is tagged with `data-vibe-internal-component-id="<id>"`
201
201
  3. Inside that subtree, `@[this.X.Y]` is rewritten to `@[_c0.X.Y]`, and `this.` in event handlers resolves at fire time by declared keys: names registered in `component({...})` address `$[id]` (`onclick="this.method()"`, `this.count++`), every other name reaches the native element (`this.value`, `this.closest(...)`, `this.focus()`). `$.this.X = ...` is always an explicit state write
202
202
  4. When the component leaves the DOM, its state entry is freed automatically
203
203
 
@@ -221,7 +221,7 @@ Multi-segment paths (`@[this.user.profile.name]`), conditionals (`<!-- if this.e
221
221
 
222
222
  How it works:
223
223
 
224
- 1. `component({...})` claims the nearest unprocessed `<component>` (or `<div class="component">`) wrapper, registers state at `$[id]`, and tags the wrapper with `data-vibe-component-id`
224
+ 1. `component({...})` claims the nearest unprocessed `<component>` (or `<div class="component">`) wrapper, registers state at `$[id]`, and tags the wrapper with `data-vibe-internal-component-id`
225
225
  2. Internally it triggers the boot pipeline, which initializes `window.$`, parses the DOM, hydrates bindings, and starts the MutationObserver — exactly once, even if multiple `<component>` blocks call `component()`
226
226
  3. From there, `@[this.X]`, `onclick="this.fn()"`, and `<!-- if this.X -->` work as documented
227
227
 
@@ -340,7 +340,7 @@ Skip reactive processing for an element:
340
340
  These are used by the runtime — don't repurpose them in your code:
341
341
 
342
342
  - `window.__vibeManifest`, `window.__vibeCompiling`, `window.__vibeComponents`, `window.__vibeIterProps` — internal registries
343
- - `data-vibe-component-id`, `data-vibe-iter-prop` — element-level bookkeeping attributes (set automatically)
343
+ - `data-vibe-internal-component-id`, `data-vibe-internal-iter-prop` — element-level bookkeeping attributes (set automatically)
344
344
 
345
345
  ### Deep Reactivity
346
346
 
package/component.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { generateComponentId } from './runtime/component.js';
2
+ import { componentIdOf, assignComponentId } from './runtime/component-registry.js';
2
3
  import { ensureBoot } from './boot.js';
3
4
 
4
5
  const component = (state = {}, config) => {
@@ -11,7 +12,7 @@ const component = (state = {}, config) => {
11
12
  (child) => child.matches?.('script[type="module"]') && /component\s*\(/.test(child.textContent),
12
13
  );
13
14
  if (!ownsCall) return false;
14
- const existingId = el.getAttribute('data-vibe-component-id');
15
+ const existingId = componentIdOf(el);
15
16
  return !existingId || !ns.components[existingId];
16
17
  });
17
18
 
@@ -20,10 +21,10 @@ const component = (state = {}, config) => {
20
21
  return;
21
22
  }
22
23
 
23
- let componentId = wrapper.getAttribute('data-vibe-component-id');
24
+ let componentId = componentIdOf(wrapper);
24
25
  if (!componentId) {
25
26
  componentId = generateComponentId();
26
- wrapper.setAttribute('data-vibe-component-id', componentId);
27
+ assignComponentId(wrapper, componentId);
27
28
  }
28
29
 
29
30
  ns.components[componentId] = state;
package/llms.txt CHANGED
@@ -179,7 +179,7 @@ For state scoped to a single component (form drafts, toggles, derived view-state
179
179
  How it works:
180
180
 
181
181
  1. `component({...})` generates a unique id (e.g. `_c0`) and stores state at `$[id]`
182
- 2. The `<script>` and every following sibling get `data-vibe-component-id="<id>"`
182
+ 2. The `<script>` and every following sibling get `data-vibe-internal-component-id="<id>"`
183
183
  3. Inside that subtree, `@[this.X.Y]` is rewritten to `@[_c0.X.Y]`; `onclick="this.fn()"` becomes `onclick="$['_c0'].fn()"`; `$.this.x = v` (in event handlers) becomes `$['_c0'].x = v`
184
184
  4. When the component leaves the DOM, its bucket is freed automatically
185
185
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "4.1.3",
3
+ "version": "4.2.0",
4
4
  "type": "module",
5
- "description": "Runtime-first reactivity for plain HTML no build step, no virtual DOM, no new syntax to learn",
5
+ "description": "Runtime-first reactivity for plain HTML \u2014 no build step, no virtual DOM, no new syntax to learn",
6
6
  "main": "index.js",
7
7
  "homepage": "https://vibe.korte.kim",
8
8
  "repository": {
@@ -14,7 +14,8 @@
14
14
  "./component": "./component.js",
15
15
  "./spa": "./spa.js",
16
16
  "./hot-module-refresh": "./hot-module-refresh.js",
17
- "./vibe.css": "./vibe.css"
17
+ "./vibe.css": "./vibe.css",
18
+ "./constants": "./runtime/constants.js"
18
19
  },
19
20
  "files": [
20
21
  "index.js",
@@ -55,7 +55,7 @@ const attrBindingsOf = (tree) => {
55
55
  const resolvedInner = resolveThisPath(m[1], tree.element);
56
56
  ms.push({ outer: m[0], inner: m[1], resolvedInner, tokens: tokensOf(resolvedInner) });
57
57
  }
58
- if (ms.length) out.push({ attrName, matches: ms });
58
+ out.push({ attrName, matches: ms });
59
59
  }
60
60
  tree._ab = out;
61
61
  tree._abSrc = tree.attributes;
@@ -407,6 +407,22 @@ const recursive = (
407
407
 
408
408
  for (const { attrName, matches: attrMatches } of attrBindingsOf(tree)) {
409
409
  const attrValue = tree.attributes[attrName];
410
+ if (!attrMatches.length) {
411
+ if (isInitialHydration) {
412
+ affected.push({
413
+ type: "attribute",
414
+ attrName,
415
+ attrValue,
416
+ matchOuter: null,
417
+ matchInner: null,
418
+ element: tree.element,
419
+ scopedState: scopedStateForHydration,
420
+ binding: null,
421
+ wasConnected: !!tree.element?.isConnected,
422
+ });
423
+ }
424
+ continue;
425
+ }
410
426
  for (const m of attrMatches) {
411
427
  const shouldAffect =
412
428
  isInitialHydration || bindingAffected(m.tokens, keySet, state, newState);
@@ -1,6 +1,12 @@
1
+ import { componentIdOf } from './component-registry.js';
1
2
  import { debugLog } from './debug.js';
2
3
  import { isInert } from './inert.js';
3
- import { PHASE_READY, FOUC_CLASS_OR_ATTR } from './constants.js';
4
+ import {
5
+ PHASE_READY,
6
+ FOUC_CLASS_OR_ATTR,
7
+ COMPONENT_ID_ATTR,
8
+ DEFER_ATTR_PREFIX,
9
+ } from './constants.js';
4
10
 
5
11
  const skipInert = (root) => ({
6
12
  acceptNode: (node) => (isInert(node, root) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT),
@@ -42,7 +48,7 @@ export const shouldCleanup = (rootElement) => {
42
48
  continue;
43
49
  }
44
50
  for (const attr of el.attributes) {
45
- if (attr.name.startsWith('data-vibe-')) continue;
51
+ if (attr.name.startsWith(DEFER_ATTR_PREFIX)) continue;
46
52
  if (el._vibeBoundAttrs?.has(attr.name)) continue;
47
53
  if (/@\[.+?\]/.test(attr.value)) {
48
54
  return false;
@@ -56,6 +62,10 @@ export const shouldCleanup = (rootElement) => {
56
62
  export const cleanup = (rootElement, debug = false) => {
57
63
  rootElement.offsetHeight;
58
64
 
65
+ document.querySelectorAll(`[${COMPONENT_ID_ATTR}]`).forEach((el) => {
66
+ if (!isInert(el, document.body)) componentIdOf(el);
67
+ });
68
+
59
69
  const isClass = FOUC_CLASS_OR_ATTR.startsWith('.');
60
70
  const cleanName = FOUC_CLASS_OR_ATTR.replace(/^\./, '').replace(/^\[/, '').replace(/\]$/, '');
61
71
 
@@ -0,0 +1,113 @@
1
+ import {
2
+ COMPONENT_ID_ATTR,
3
+ CONDITIONAL_START_REGEX,
4
+ ITERATION_START_REGEX,
5
+ } from './constants.js';
6
+ const ids = new WeakMap();
7
+ const refs = new Map();
8
+
9
+ const ATTR = COMPONENT_ID_ATTR;
10
+
11
+ const remember = (element, id) => {
12
+ ids.set(element, id);
13
+ let bucket = refs.get(id);
14
+ if (!bucket) refs.set(id, (bucket = new Set()));
15
+ bucket.add(new WeakRef(element));
16
+ };
17
+
18
+ export const componentIdOf = (element) => {
19
+ const known = ids.get(element);
20
+ if (known) return known;
21
+ const carried = element.getAttribute?.(ATTR);
22
+ if (!carried) return null;
23
+ remember(element, carried);
24
+ if (element.isConnected) element.removeAttribute(ATTR);
25
+ return carried;
26
+ };
27
+
28
+ export const assignComponentId = (element, id) => remember(element, id);
29
+
30
+ export const componentIdTaken = (id) => {
31
+ const bucket = refs.get(id);
32
+ if (!bucket) return false;
33
+ for (const ref of bucket) {
34
+ const element = ref.deref();
35
+ if (!element) {
36
+ bucket.delete(ref);
37
+ continue;
38
+ }
39
+ if (element.isConnected) return true;
40
+ }
41
+ if (!bucket.size) refs.delete(id);
42
+ return false;
43
+ };
44
+
45
+ export const closestComponentWrapper = (element) => {
46
+ for (let current = element; current; current = current.parentElement) {
47
+ if (componentIdOf(current)) return current;
48
+ }
49
+ return null;
50
+ };
51
+
52
+ export const closestComponentId = (element) => {
53
+ const wrapper = element ? closestComponentWrapper(element) : null;
54
+ return wrapper ? componentIdOf(wrapper) : null;
55
+ };
56
+
57
+ export const eachComponentIn = (root, visit) => {
58
+ if (!root || (root.nodeType !== 1 && root.nodeType !== 11)) return;
59
+ if (root.nodeType === 1) {
60
+ const id = componentIdOf(root);
61
+ if (id) visit(root, id);
62
+ }
63
+ const walker = (root.ownerDocument ?? document).createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
64
+ let element;
65
+ while ((element = walker.nextNode())) {
66
+ const id = componentIdOf(element);
67
+ if (id) visit(element, id);
68
+ }
69
+ };
70
+
71
+ const insideDirectiveBlock = (element, root) => {
72
+ for (let node = element; node && node !== root; node = node.parentNode) {
73
+ let depth = 0;
74
+ for (let sibling = node.previousSibling; sibling; sibling = sibling.previousSibling) {
75
+ if (sibling.nodeType !== 8) continue;
76
+ const text = sibling.textContent.trim();
77
+ if (text === '/if' || text === '/each') depth++;
78
+ else if (CONDITIONAL_START_REGEX.test(text) || ITERATION_START_REGEX.test(text)) {
79
+ if (!depth) return true;
80
+ depth--;
81
+ }
82
+ }
83
+ }
84
+ return false;
85
+ };
86
+
87
+ export const ingestComponentIds = (root) => {
88
+ if (!root || (root.nodeType !== 1 && root.nodeType !== 11)) return;
89
+ const claim = (element) => {
90
+ const carried = element.getAttribute(ATTR);
91
+ if (!carried) return;
92
+ if (insideDirectiveBlock(element, root)) return;
93
+ remember(element, carried);
94
+ element.removeAttribute(ATTR);
95
+ };
96
+ if (root.nodeType === 1) claim(root);
97
+ root.querySelectorAll(`[${ATTR}]`).forEach(claim);
98
+ };
99
+
100
+ const iterProps = new WeakSet();
101
+
102
+ export const markIterProp = (element) => iterProps.add(element);
103
+
104
+ export const hasIterProp = (element) => iterProps.has(element);
105
+
106
+ export const eachIterPropIn = (root, visit) => {
107
+ if (!root || (root.nodeType !== 1 && root.nodeType !== 11)) return;
108
+ const walker = (root.ownerDocument ?? document).createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
109
+ let element;
110
+ while ((element = walker.nextNode())) {
111
+ if (iterProps.has(element)) visit(element);
112
+ }
113
+ };
@@ -1,3 +1,12 @@
1
+ import {
2
+ componentIdOf,
3
+ componentIdTaken,
4
+ closestComponentWrapper,
5
+ eachComponentIn,
6
+ ingestComponentIds,
7
+ markIterProp,
8
+ hasIterProp,
9
+ } from './component-registry.js';
1
10
  import { debugLog } from './debug.js';
2
11
  import { shouldCleanup } from './cleanup.js';
3
12
  import { isInert } from './inert.js';
@@ -10,6 +19,14 @@ import {
10
19
  STATE_THIS_PROP_REGEX,
11
20
  CONDITIONAL_START_REGEX,
12
21
  ITERATION_START_REGEX,
22
+ COMPONENT_ID_PREFIX,
23
+ MODULE_SCRIPT_SELECTOR,
24
+ COMPONENT_ID_ATTR,
25
+ DEFER_ATTR_PREFIX,
26
+ DEFERRED_SRC_ATTR,
27
+ KEY_ATTR,
28
+ COMPONENT_SRC_SELECTOR,
29
+ FOUC_CLASS_OR_ATTR,
13
30
  } from './constants.js';
14
31
  import { evalInScope } from './utils.js';
15
32
  import { bumpIterPropGeneration, parkFetchableSrc } from './iteration-utils.js';
@@ -31,7 +48,7 @@ export { activeOutgoingRoots, isComponentWrapper };
31
48
  let componentCounter = 1000000;
32
49
 
33
50
  export const generateComponentId = () => {
34
- return `_c${componentCounter++}`;
51
+ return `${COMPONENT_ID_PREFIX}${componentCounter++}`;
35
52
  };
36
53
 
37
54
  const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -41,7 +58,7 @@ export const trackComponentOwnership = () => {};
41
58
  export const releaseOrphanedComponentState = (collectedIds) => {
42
59
  if (!collectedIds || collectedIds.size === 0) return;
43
60
  for (const id of collectedIds) {
44
- if (document.querySelector(`[data-vibe-component-id="${id}"]`)) continue;
61
+ if (componentIdTaken(id)) continue;
45
62
  delete window.__vibe?.components?.[id];
46
63
  delete window.$[id];
47
64
  runComponentCleanups(id);
@@ -94,13 +111,7 @@ const createScopedDollar = (componentId) => {
94
111
 
95
112
  export const collectComponentIds = (node, into = new Set()) => {
96
113
  if (!node) return into;
97
- if (node.nodeType === 1) {
98
- const id = node.getAttribute('data-vibe-component-id');
99
- if (id) into.add(id);
100
- node.querySelectorAll?.('[data-vibe-component-id]').forEach((el) => {
101
- into.add(el.getAttribute('data-vibe-component-id'));
102
- });
103
- }
114
+ eachComponentIn(node, (el, id) => into.add(id));
104
115
  return into;
105
116
  };
106
117
 
@@ -164,14 +175,14 @@ const collectMountedModuleScripts = (nodes, out) => {
164
175
  continue;
165
176
  }
166
177
  if (depth > 0 || node.nodeType !== 1) continue;
167
- if (node.matches?.('script[type="vibe-module"]')) out.push(node);
178
+ if (node.matches?.(MODULE_SCRIPT_SELECTOR)) out.push(node);
168
179
  else collectMountedModuleScripts(node.childNodes, out);
169
180
  }
170
181
  };
171
182
 
172
183
  const advanceComponentCounterPastIds = (root) => {
173
- root.querySelectorAll('[data-vibe-component-id]').forEach((el) => {
174
- const m = el.getAttribute('data-vibe-component-id')?.match(/^_c(\d+)$/);
184
+ eachComponentIn(root, (el, id) => {
185
+ const m = id.match(new RegExp(`^${COMPONENT_ID_PREFIX}(\\d+)$`));
175
186
  if (m) componentCounter = Math.max(componentCounter, Number(m[1]) + 1);
176
187
  });
177
188
  };
@@ -220,8 +231,8 @@ const executeScriptUnit = (componentId, content, hasImports, scopedDollar, compo
220
231
  };
221
232
 
222
233
  const wrapperSetupScript = (wrapper) => {
223
- for (const s of wrapper.querySelectorAll('script[type="vibe-module"]')) {
224
- if (s.closest('[data-vibe-component-id]') === wrapper) return s;
234
+ for (const s of wrapper.querySelectorAll(MODULE_SCRIPT_SELECTOR)) {
235
+ if (closestComponentWrapper(s) === wrapper) return s;
225
236
  }
226
237
  return null;
227
238
  };
@@ -239,10 +250,10 @@ const runVibeModuleScripts = (scripts, silent = false) => {
239
250
  if (!rawContent) continue;
240
251
  script.__vibeExecuted = true;
241
252
 
242
- const wrapper = script.closest('[data-vibe-component-id]');
253
+ const wrapper = closestComponentWrapper(script);
243
254
  const componentId =
244
255
  wrapper && wrapperSetupScript(wrapper) === script
245
- ? wrapper.getAttribute('data-vibe-component-id')
256
+ ? componentIdOf(wrapper)
246
257
  : generateComponentId();
247
258
 
248
259
  const { content, hasImports } = transformScriptContent(rawContent);
@@ -359,8 +370,8 @@ const tagScriptSiblings = (script, componentId) => {
359
370
  if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
360
371
  break;
361
372
  }
362
- if (!sibling.hasAttribute('data-vibe-component-id')) {
363
- sibling.setAttribute('data-vibe-component-id', componentId);
373
+ if (!sibling.hasAttribute(COMPONENT_ID_ATTR)) {
374
+ sibling.setAttribute(COMPONENT_ID_ATTR, componentId);
364
375
  rewriteThisBindings(sibling, componentId);
365
376
  }
366
377
  sibling = sibling.nextElementSibling;
@@ -488,7 +499,7 @@ const processSingle = (el, debug) => {
488
499
  if (!props) {
489
500
  props = {};
490
501
  Array.from(el.attributes).forEach((attr) => {
491
- if (attr.name !== 'src' && attr.name !== 'key' && !attr.name.startsWith('data-vibe-')) {
502
+ if (attr.name !== 'src' && attr.name !== 'key' && !attr.name.startsWith(DEFER_ATTR_PREFIX)) {
492
503
  props[attr.name] = attr.value;
493
504
  }
494
505
  });
@@ -572,37 +583,37 @@ const processSingle = (el, debug) => {
572
583
  newWrapper.innerHTML = transformedHtml;
573
584
  parkFetchableSrc(newWrapper);
574
585
  if (firstComponentId !== null) {
575
- newWrapper.setAttribute('data-vibe-component-id', firstComponentId);
586
+ newWrapper.setAttribute(COMPONENT_ID_ATTR, firstComponentId);
576
587
  }
577
588
  newWrapper._vibeRawSource = html;
578
589
  newWrapper._vibeMountedSrc = src;
579
590
  newWrapper._vibeRemountProps = props;
580
591
  newWrapper._vibeSlotContent = children;
581
- const srcBinding = el._vibeSrcBinding ?? el.getAttribute('data-vibe-src');
582
- if (srcBinding) newWrapper.setAttribute('data-vibe-src', srcBinding);
583
- const keyBinding = el._vibeKeyBinding ?? el.getAttribute('data-vibe-key');
584
- if (keyBinding) newWrapper.setAttribute('data-vibe-key', keyBinding);
592
+ const srcBinding = el._vibeSrcBinding ?? el.getAttribute(DEFERRED_SRC_ATTR);
593
+ if (srcBinding) newWrapper.setAttribute(DEFERRED_SRC_ATTR, srcBinding);
594
+ const keyBinding = el._vibeKeyBinding ?? el.getAttribute(KEY_ATTR);
595
+ if (keyBinding) newWrapper.setAttribute(KEY_ATTR, keyBinding);
585
596
  if (el._vibeMountedKey !== undefined) newWrapper._vibeMountedKey = el._vibeMountedKey;
586
597
  if (el._vibeIterPropIds) {
587
598
  newWrapper._vibeIterPropIds = el._vibeIterPropIds;
588
599
  el._vibeIterPropIds = null;
589
600
  }
590
- if (el.hasAttribute('data-vibe-iter-prop')) {
591
- newWrapper.setAttribute('data-vibe-iter-prop', '');
592
- el.removeAttribute('data-vibe-iter-prop');
601
+ if (hasIterProp(el)) {
602
+ markIterProp(newWrapper);
593
603
  bumpIterPropGeneration();
594
604
  }
595
605
  if (el._vibeIterPropExprs) {
596
606
  newWrapper._vibeIterPropExprs = el._vibeIterPropExprs;
597
607
  el._vibeIterPropExprs = null;
598
608
  }
599
- newWrapper.setAttribute('vibe-fouc', '');
609
+ newWrapper.setAttribute(FOUC_CLASS_OR_ATTR, '');
600
610
  el._vibeReplacedBy = newWrapper;
601
611
  if (el._vibeOutgoing) {
602
612
  stageIncoming(el, newWrapper);
603
613
  } else {
604
614
  el.replaceWith(newWrapper);
605
615
  }
616
+ ingestComponentIds(newWrapper);
606
617
  debugLog(fromCache ? PHASE_FETCH_CACHED : PHASE_FETCH, src, debug);
607
618
 
608
619
  notifyChanged(registeredComponentIds);
@@ -615,13 +626,13 @@ const processSingle = (el, debug) => {
615
626
  const tryReveal = () => {
616
627
  if (!newWrapper.isConnected) {
617
628
  abandonStaged(newWrapper);
618
- newWrapper.removeAttribute('vibe-fouc');
629
+ newWrapper.removeAttribute(FOUC_CLASS_OR_ATTR);
619
630
  unfouc();
620
631
  return;
621
632
  }
622
633
  if (!scriptsDone || !shouldCleanup(newWrapper)) return;
623
634
  commitStaged(newWrapper);
624
- newWrapper.removeAttribute('vibe-fouc');
635
+ newWrapper.removeAttribute(FOUC_CLASS_OR_ATTR);
625
636
  unfouc();
626
637
  };
627
638
  const unfouc = window.$.on('afterDomMutation', tryReveal);
@@ -658,7 +669,7 @@ const processSingle = (el, debug) => {
658
669
 
659
670
  export const processComponent = (rootElement, onComplete, config = {}) => {
660
671
  const debug = !!config?.debug;
661
- const allComponents = rootElement.querySelectorAll('component[src], div.component[src]');
672
+ const allComponents = rootElement.querySelectorAll(COMPONENT_SRC_SELECTOR);
662
673
 
663
674
  if (allComponents.length === 0) {
664
675
  if (onComplete) queueMicrotask(() => onComplete());
@@ -1,8 +1,32 @@
1
- export const VERSION = '4.1.3';
1
+ export const VERSION = '4.2.0';
2
2
 
3
3
  export const DEBUGGER_NAME = '[vibe-debug]:';
4
4
  export const FOUC_CLASS_OR_ATTR = 'vibe-fouc';
5
5
  export const DEHYDRATE_CLASS_OR_ATTR = 'vibe-dehydrate';
6
+ export const STAGED_ATTR = 'vibe-staged';
7
+
8
+ export const DEFER_ATTR_PREFIX = 'data-vibe-';
9
+ export const INTERNAL_ATTR_PREFIX = `${DEFER_ATTR_PREFIX}internal-`;
10
+ export const COMPONENT_ID_ATTR = `${INTERNAL_ATTR_PREFIX}component-id`;
11
+ export const KEY_ATTR = `${INTERNAL_ATTR_PREFIX}key`;
12
+ export const BATCH_ATTR = `${INTERNAL_ATTR_PREFIX}batch`;
13
+ export const NAMEBIND_ATTR = `${INTERNAL_ATTR_PREFIX}namebind`;
14
+ export const CSS_COVER_ATTR = `${INTERNAL_ATTR_PREFIX}cover`;
15
+ export const CSS_LAYOUT_ATTR = `${INTERNAL_ATTR_PREFIX}css`;
16
+ export const DEFERRED_SRC_ATTR = `${DEFER_ATTR_PREFIX}src`;
17
+
18
+ export const MODULE_SCRIPT_TYPE = 'vibe-module';
19
+ export const MODULE_SCRIPT_SELECTOR = `script[type="${MODULE_SCRIPT_TYPE}"]`;
20
+
21
+ export const HYPERSPEED_DIR = 'vibe-hyperspeed';
22
+ export const MANIFEST_SUFFIX = '.manifest.js';
23
+
24
+ export const COMPONENT_ID_PREFIX = '_c';
25
+ export const COMPONENT_ID_REGEX = new RegExp(`^${COMPONENT_ID_PREFIX}\\d+$`);
26
+ export const ITER_PROP_PREFIX = '_p';
27
+ export const ITER_PROP_PATH = '__vibe.iterProps';
28
+
29
+ export const COMPONENT_SRC_SELECTOR = 'component[src], div.component[src]';
6
30
 
7
31
  export const PHASE_ATTACH = 'Attached';
8
32
  export const PHASE_MANIFEST = 'Manifested';
@@ -2,7 +2,15 @@ import { updateIteration } from './iterate.js';
2
2
  import { updateConditional, managedNodes } from './conditionals.js';
3
3
  import { liveComponentWrapper, remountComponent, forceRemount } from './component.js';
4
4
  import { isComponentWrapper, isRemountTrigger, parkRootFor, parkBinding } from './staging.js';
5
- import { VALUE_ATTRS, DOM_PROPERTIES, BINDING_REGEX, PURE_BINDING_REGEX } from './constants.js';
5
+ import {
6
+ VALUE_ATTRS,
7
+ DOM_PROPERTIES,
8
+ BINDING_REGEX,
9
+ PURE_BINDING_REGEX,
10
+ NAMEBIND_ATTR,
11
+ DEFER_ATTR_PREFIX,
12
+ DEFERRED_SRC_ATTR,
13
+ } from './constants.js';
6
14
  import {
7
15
  evalInScope,
8
16
  resolveCaseInsensitivePath,
@@ -60,8 +68,8 @@ const applyNameBinding = (aff, effectiveState) => {
60
68
  element._vibeNameBindings.delete(nameBinding);
61
69
  }
62
70
 
63
- if (element.hasAttribute('data-vibe-namebind')) {
64
- element.removeAttribute('data-vibe-namebind');
71
+ if (element.hasAttribute(NAMEBIND_ATTR)) {
72
+ element.removeAttribute(NAMEBIND_ATTR);
65
73
  }
66
74
  } catch (e) {
67
75
  console.error('Error hydrating name binding:', e);
@@ -71,6 +79,8 @@ const applyNameBinding = (aff, effectiveState) => {
71
79
  const applyAttributeBinding = (aff, effectiveState) => {
72
80
  const { attrName, attrValue, element } = aff;
73
81
  try {
82
+ const deferredName = `${DEFER_ATTR_PREFIX}${attrName}`;
83
+ if (element.hasAttribute(deferredName)) element.removeAttribute(deferredName);
74
84
  const isPureBinding = attrValue.match(PURE_BINDING_REGEX);
75
85
  const isDomProperty = DOM_PROPERTIES.includes(attrName);
76
86
  const isValueAttr =
@@ -209,7 +219,7 @@ export default (affected, state, manifest = {}, oldState = {}) => {
209
219
  remountComponent(live, newSrc);
210
220
  if (live._vibeOutgoing) (outgoingRoots ??= []).push(live);
211
221
  } else if (live.hasAttribute('src')) {
212
- live.setAttribute('data-vibe-src', attrValue);
222
+ live.setAttribute(DEFERRED_SRC_ATTR, attrValue);
213
223
  live.removeAttribute('src');
214
224
  }
215
225
  return;
package/runtime/index.js CHANGED
@@ -22,6 +22,8 @@ import {
22
22
  PHASE_MUTATE,
23
23
  PHASE_HYPERSPEED,
24
24
  PHASE_READY,
25
+ STAGED_ATTR,
26
+ FOUC_CLASS_OR_ATTR,
25
27
  } from './constants.js';
26
28
  import { processComponent, abortComponentFetch, collectComponentIds, releaseOrphanedComponentState, renderComponentTemplate, executeCompiledComponentScripts } from './component.js';
27
29
  import { configureComponentCache, clearComponentCache } from './component-cache.js';
@@ -545,7 +547,7 @@ const main = (s, config = {}, stringSelector = '') => {
545
547
  const lateReady = [];
546
548
  let lateReadyWatch = null;
547
549
  const mountsSettled = () =>
548
- !document.querySelector('[vibe-staged], [vibe-fouc]') && shouldCleanup(rootElement);
550
+ !document.querySelector(`[${STAGED_ATTR}], [${FOUC_CLASS_OR_ATTR}]`) && shouldCleanup(rootElement);
549
551
  const flushLateReady = () => {
550
552
  if (lateReady.length === 0 || !mountsSettled()) return;
551
553
  if (lateReadyWatch) {
@@ -1,3 +1,4 @@
1
+ import { ingestComponentIds, markIterProp, eachIterPropIn } from './component-registry.js';
1
2
  import parse from './parse.js';
2
3
  import affected, { nodeSubscriberOf } from './affected.js';
3
4
  import hydrate from './hydrate.js';
@@ -13,6 +14,15 @@ import {
13
14
  VALUE_ATTRS,
14
15
  DOM_PROPERTIES,
15
16
  THIS_PROP_REGEX,
17
+ MODULE_SCRIPT_SELECTOR,
18
+ COMPONENT_ID_PREFIX,
19
+ COMPONENT_ID_REGEX,
20
+ BATCH_ATTR,
21
+ NAMEBIND_ATTR,
22
+ ITER_PROP_PREFIX,
23
+ ITER_PROP_PATH,
24
+ COMPONENT_ID_ATTR,
25
+ COMPONENT_SRC_SELECTOR,
16
26
  } from './constants.js';
17
27
 
18
28
  import * as compiled from './pre-compiled-iterations.js';
@@ -40,10 +50,10 @@ const hasNestedStructures = (tree) => {
40
50
  };
41
51
 
42
52
  const hasComponentSrc = (templateEl) =>
43
- !!templateEl.querySelector?.('component[src], div.component[src]');
53
+ !!templateEl.querySelector?.(COMPONENT_SRC_SELECTOR);
44
54
 
45
55
  const hasInlinedComponentScript = (templateEl) =>
46
- !!templateEl.querySelector?.('script[type="vibe-module"]');
56
+ !!templateEl.querySelector?.(MODULE_SCRIPT_SELECTOR);
47
57
 
48
58
  const canUseBatchRender = (template) =>
49
59
  !globalThis.__vibe?.forceClonePath &&
@@ -98,11 +108,13 @@ const isValueStyleAttr = (attrName) =>
98
108
  attrName.startsWith('aria-') ||
99
109
  attrName.startsWith('on');
100
110
 
101
- const CID_ROOT_REGEX =
102
- /('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|(?<![\w$.])_c(\d+)\b/g;
111
+ const CID_ROOT_REGEX = new RegExp(
112
+ String.raw`('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|(?<![\w$.])${COMPONENT_ID_PREFIX}(\d+)\b`,
113
+ 'g',
114
+ );
103
115
 
104
116
  export const canonicalizeComponentIds = (expr) =>
105
- expr.replace(CID_ROOT_REGEX, (m, literal, id) => (literal !== undefined ? literal : `$['_c${id}']`));
117
+ expr.replace(CID_ROOT_REGEX, (m, literal, id) => (literal !== undefined ? literal : `$['${COMPONENT_ID_PREFIX}${id}']`));
106
118
 
107
119
  const nameBindingEmit = (exprSrc) =>
108
120
  '${(()=>{const _v=_e(()=>(' + exprSrc + '));return _v?\' \'+_v+\'=""\':\'\';})()}';
@@ -128,14 +140,14 @@ export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, ancho
128
140
  domPropertyWrites.push({ prop: attr.name, expr });
129
141
  }
130
142
  if (indexes.length > 0) {
131
- el.setAttribute('data-vibe-batch', indexes.join(','));
143
+ el.setAttribute(BATCH_ATTR, indexes.join(','));
132
144
  }
133
145
  }
134
146
 
135
147
  let templateHtml = tplClone.innerHTML.trim();
136
148
 
137
149
  templateHtml = templateHtml.replace(
138
- /\sdata-vibe-namebind="((?:@\[(?:[^[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\])+)"/g,
150
+ new RegExp(String.raw`\s${NAMEBIND_ATTR}="((?:@\[(?:[^[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\])+)"`, 'g'),
139
151
  (_, bindings) =>
140
152
  bindings.replace(/@\[(?:[^[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\]/g, (b) => ' ' + b + '=""'),
141
153
  );
@@ -241,7 +253,7 @@ export const releaseOrphanedIterationProps = (nodes) => {
241
253
  el._vibeIterPropIds = null;
242
254
  };
243
255
  free(node);
244
- node.querySelectorAll?.('[data-vibe-iter-prop]').forEach(free);
256
+ eachIterPropIn(node, free);
245
257
  }
246
258
  };
247
259
 
@@ -275,7 +287,7 @@ const resolveSlotContentBindings = (el, scopedState, aliases) => {
275
287
  return undefined;
276
288
  }
277
289
  if (value === undefined) return undefined;
278
- id = `_p${__vibeIterPropCounter++}`;
290
+ id = `${ITER_PROP_PREFIX}${__vibeIterPropCounter++}`;
279
291
  registry[id] = value;
280
292
  idByExpr.set(expr, id);
281
293
  (el._vibeIterPropExprs = el._vibeIterPropExprs || []).push({ id, attrName: null, expr });
@@ -309,7 +321,7 @@ const resolveSlotContentBindings = (el, scopedState, aliases) => {
309
321
  }
310
322
  if (idByExpr.size) {
311
323
  el._vibeSlotContent = rewritten;
312
- el.setAttribute('data-vibe-iter-prop', '');
324
+ markIterProp(el);
313
325
  bumpIterPropGeneration();
314
326
  }
315
327
  };
@@ -330,9 +342,9 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
330
342
  for (let n = 0; n < nodes.length; n++) {
331
343
  const node = nodes[n];
332
344
  if (node.nodeType !== 1) continue;
333
- const components = node.matches?.('component[src], div.component[src]')
334
- ? [node, ...node.querySelectorAll('component[src], div.component[src]')]
335
- : [...node.querySelectorAll('component[src], div.component[src]')];
345
+ const components = node.matches?.(COMPONENT_SRC_SELECTOR)
346
+ ? [node, ...node.querySelectorAll(COMPONENT_SRC_SELECTOR)]
347
+ : [...node.querySelectorAll(COMPONENT_SRC_SELECTOR)];
336
348
  for (let i = 0; i < components.length; i++) {
337
349
  const el = components[i];
338
350
  const attrs = el.attributes;
@@ -346,7 +358,7 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
346
358
  /\bthis\b/.test(expr) ||
347
359
  (aliases && extractDependencies(expr).some((d) => aliases.has(d)));
348
360
  if (!usesLocalScope) {
349
- el.setAttribute('data-vibe-iter-prop', '');
361
+ markIterProp(el);
350
362
  bumpIterPropGeneration();
351
363
  el._vibeIterPropExprs = el._vibeIterPropExprs || [];
352
364
  continue;
@@ -355,10 +367,10 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
355
367
  const value = evalInScope(expr, scopedState, el);
356
368
  if (value === undefined) continue;
357
369
  const registry = ensureIterPropsRegistry();
358
- const id = `_p${__vibeIterPropCounter++}`;
370
+ const id = `${ITER_PROP_PREFIX}${__vibeIterPropCounter++}`;
359
371
  registry[id] = value;
360
- el.setAttribute(attr.name, `@[window.__vibe.iterProps.${id}]`);
361
- el.setAttribute('data-vibe-iter-prop', '');
372
+ el.setAttribute(attr.name, `@[window.${ITER_PROP_PATH}.${id}]`);
373
+ markIterProp(el);
362
374
  bumpIterPropGeneration();
363
375
  (el._vibeIterPropIds = el._vibeIterPropIds || []).push(id);
364
376
  (el._vibeIterPropExprs = el._vibeIterPropExprs || []).push({ id, attrName: attr.name, expr });
@@ -401,7 +413,7 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
401
413
  return changed;
402
414
  };
403
415
 
404
- const REGISTRY_SLOT_REGEX = /__vibe\.iterProps\.(_p\d+)/;
416
+ const REGISTRY_SLOT_REGEX = new RegExp(`${ITER_PROP_PATH.replace('.', String.raw`\.`)}\\.(${ITER_PROP_PREFIX}\\d+)`);
405
417
  const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope, changedSlots) => {
406
418
  if (!tree) return;
407
419
  if (tree.type === 'iteration') {
@@ -435,12 +447,12 @@ const applyDomPropertyWrites = (instances, array, state, itemAlias, indexAlias,
435
447
  if (!root || root.nodeType !== 1) continue;
436
448
  const localState = { ...state, [itemAlias]: array[i], [indexAlias]: i };
437
449
  const tagged = [];
438
- if (root.matches?.('[data-vibe-batch]')) tagged.push(root);
439
- const found = root.querySelectorAll?.('[data-vibe-batch]');
450
+ if (root.matches?.(`[${BATCH_ATTR}]`)) tagged.push(root);
451
+ const found = root.querySelectorAll?.(`[${BATCH_ATTR}]`);
440
452
  if (found) for (let f = 0; f < found.length; f++) tagged.push(found[f]);
441
453
  for (let t = 0; t < tagged.length; t++) {
442
454
  const el = tagged[t];
443
- const indexes = el.getAttribute('data-vibe-batch').split(',');
455
+ const indexes = el.getAttribute(BATCH_ATTR).split(',');
444
456
  for (let k = 0; k < indexes.length; k++) {
445
457
  const { prop, expr } = domPropertyWrites[indexes[k] | 0];
446
458
  const value = evalInScope(expr, localState, el);
@@ -458,7 +470,7 @@ const applyDomPropertyWrites = (instances, array, state, itemAlias, indexAlias,
458
470
  el.removeAttribute(prop);
459
471
  }
460
472
  }
461
- el.removeAttribute('data-vibe-batch');
473
+ el.removeAttribute(BATCH_ATTR);
462
474
  }
463
475
  }
464
476
  };
@@ -520,6 +532,7 @@ const renderBatch = (iterationNode, array, state, parent, endComment, parentScop
520
532
  instances[i] = { element: kids[i], item: array[i], index: i };
521
533
  }
522
534
 
535
+ ingestComponentIds(frag);
523
536
  parent.insertBefore(frag, endComment);
524
537
  iterationNode.runtime.instances = instances;
525
538
 
@@ -628,15 +641,15 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
628
641
  return cloned;
629
642
  };
630
643
 
631
- const COMPONENT_ID = /^_c\d+$/;
644
+ const COMPONENT_ID = COMPONENT_ID_REGEX;
632
645
 
633
646
  const isolateInlinedComponentIds = (container) => {
634
647
  const remap = new Map();
635
- for (const el of container.querySelectorAll('[data-vibe-component-id]')) {
636
- const oldId = el.getAttribute('data-vibe-component-id');
648
+ for (const el of container.querySelectorAll(`[${COMPONENT_ID_ATTR}]`)) {
649
+ const oldId = el.getAttribute(COMPONENT_ID_ATTR);
637
650
  if (!COMPONENT_ID.test(oldId)) continue;
638
651
  if (!remap.has(oldId)) remap.set(oldId, generateComponentId());
639
- el.setAttribute('data-vibe-component-id', remap.get(oldId));
652
+ el.setAttribute(COMPONENT_ID_ATTR, remap.get(oldId));
640
653
  }
641
654
  if (!remap.size) return false;
642
655
 
@@ -652,13 +665,13 @@ const isolateInlinedComponentIds = (container) => {
652
665
  const walk = (node) => {
653
666
  if (node.nodeType === 1) {
654
667
  for (const attr of node.attributes) {
655
- if (attr.value.includes('_c')) {
668
+ if (attr.value.includes(COMPONENT_ID_PREFIX)) {
656
669
  const next = rewrite(attr.value);
657
670
  if (next !== attr.value) attr.value = next;
658
671
  }
659
672
  }
660
673
  for (const child of node.childNodes) walk(child);
661
- } else if (node.nodeType === 3 && node.textContent.includes('_c')) {
674
+ } else if (node.nodeType === 3 && node.textContent.includes(COMPONENT_ID_PREFIX)) {
662
675
  node.textContent = rewrite(node.textContent);
663
676
  }
664
677
  };
@@ -684,7 +697,7 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, c
684
697
  }
685
698
  }
686
699
 
687
- const components = parseContainer.querySelectorAll('component[src], div.component[src]');
700
+ const components = parseContainer.querySelectorAll(COMPONENT_SRC_SELECTOR);
688
701
  const scopeKeys = overlayKeysOf(scopedState);
689
702
  for (let i = 0; i < components.length; i++) {
690
703
  const el = components[i];
@@ -717,6 +730,8 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, c
717
730
  hydrate(affectedElements, scopedState);
718
731
  }
719
732
 
733
+ ingestComponentIds(parseContainer);
734
+
720
735
  return {
721
736
  element: firstElement,
722
737
  tree,
@@ -1179,6 +1194,7 @@ const renderInstances = (iterationNode, array, state, manifest, parentScope) =>
1179
1194
  clonedNodes: built.clonedNodes, scopedState: built.scopedState,
1180
1195
  });
1181
1196
  }
1197
+ ingestComponentIds(frag);
1182
1198
  parent.insertBefore(frag, endComment);
1183
1199
  iterationNode.runtime.instances = instances;
1184
1200
  stampScopes(iterationNode, manifest, parentScope);
@@ -1,3 +1,4 @@
1
+ import { eachIterPropIn } from './component-registry.js';
1
2
  import {
2
3
  ITERATION_REGEX,
3
4
  ITERATION_START_REGEX,
@@ -5,6 +6,7 @@ import {
5
6
  BINDING_REGEX,
6
7
  FETCH_SRC_ATTRS,
7
8
  FETCH_SRC_SELECTOR,
9
+ DEFER_ATTR_PREFIX,
8
10
  } from './constants.js';
9
11
 
10
12
  export const parkFetchableSrc = (root) => {
@@ -14,7 +16,7 @@ export const parkFetchableSrc = (root) => {
14
16
  const value = elements[i].getAttribute(attr);
15
17
  BINDING_REGEX.lastIndex = 0;
16
18
  if (value && BINDING_REGEX.test(value)) {
17
- elements[i].setAttribute(`data-vibe-${attr}`, value);
19
+ elements[i].setAttribute(`${DEFER_ATTR_PREFIX}${attr}`, value);
18
20
  elements[i].removeAttribute(attr);
19
21
  }
20
22
  }
@@ -55,7 +57,7 @@ export const markBoundValues = (root, html) => {
55
57
  continue;
56
58
  }
57
59
  for (const attr of node.attributes) {
58
- if (!attr.name.startsWith('data-vibe-') && attr.value.includes('@[')) {
60
+ if (!attr.name.startsWith(DEFER_ATTR_PREFIX) && attr.value.includes('@[')) {
59
61
  (node._vibeBoundAttrs ??= new Set()).add(attr.name);
60
62
  }
61
63
  }
@@ -236,8 +238,7 @@ export const iterPropWrappersOf = (node) => {
236
238
  return list;
237
239
  }
238
240
  const list = [];
239
- const found = node.querySelectorAll?.('[data-vibe-iter-prop]');
240
- if (found) for (let i = 0; i < found.length; i++) list.push(found[i]);
241
+ eachIterPropIn(node, (el) => list.push(el));
241
242
  node._vibeIterPropWrappers = { gen: iterPropGeneration, list };
242
243
  return list;
243
244
  };
package/runtime/parse.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { closestComponentId } from './component-registry.js';
1
2
  import {
2
3
  findEndComment,
3
4
  findEndCommentIndex,
@@ -14,15 +15,16 @@ import {
14
15
  STATE_THIS_PROP_REGEX,
15
16
  FETCH_SRC_ATTRS,
16
17
  FETCH_SRC_ELEMENTS,
18
+ DEFERRED_SRC_ATTR,
19
+ KEY_ATTR,
20
+ NAMEBIND_ATTR,
21
+ DEFER_ATTR_PREFIX,
22
+ INTERNAL_ATTR_PREFIX,
17
23
  } from './constants.js';
18
24
  import { rewriteHandlerAliases } from './loop-scope.js';
19
25
  import './this-scope.js';
20
26
 
21
- const findComponentIdForElement = (element) => {
22
- if (!element?.closest) return null;
23
- const wrapper = element.closest('[data-vibe-component-id]');
24
- return wrapper ? wrapper.getAttribute('data-vibe-component-id') : null;
25
- };
27
+ const findComponentIdForElement = (element) => closestComponentId(element);
26
28
 
27
29
  const warnedStaticKeys = new Set();
28
30
  const warnStaticKey = (key) => {
@@ -37,11 +39,11 @@ const captureAttributeBindings = (element, aliasSet) => {
37
39
  const nodeName = element.nodeName;
38
40
  const isFetchedComponent =
39
41
  (nodeName === 'COMPONENT' || (nodeName === 'DIV' && element.classList?.contains('component'))) &&
40
- (element.hasAttribute?.('src') || element.hasAttribute?.('data-vibe-src'));
42
+ (element.hasAttribute?.('src') || element.hasAttribute?.(DEFERRED_SRC_ATTR));
41
43
 
42
44
  if (isFetchedComponent) {
43
- const src = element.getAttribute?.('data-vibe-src') ?? element.getAttribute?.('src');
44
- const key = element.getAttribute?.('data-vibe-key') ?? element.getAttribute?.('key');
45
+ const src = element.getAttribute?.(DEFERRED_SRC_ATTR) ?? element.getAttribute?.('src');
46
+ const key = element.getAttribute?.(KEY_ATTR) ?? element.getAttribute?.('key');
45
47
  const attributes = {};
46
48
  BINDING_REGEX.lastIndex = 0;
47
49
  if (BINDING_REGEX.test(src)) attributes.src = src;
@@ -61,30 +63,30 @@ const captureAttributeBindings = (element, aliasSet) => {
61
63
  const attributes = {};
62
64
  const nameBindings = [];
63
65
  const isFetchableElement = FETCH_SRC_ELEMENTS.includes(nodeName);
64
- let srcToPark = null;
66
+ let toPark = null;
67
+ let deferredNames = null;
65
68
 
66
69
  for (let j = 0; j < element.attributes.length; j++) {
67
70
  const attr = element.attributes[j];
68
71
  BINDING_REGEX.lastIndex = 0;
69
72
 
70
- if (attr.name === 'data-vibe-namebind') {
73
+ if (attr.name === NAMEBIND_ATTR) {
71
74
  nameBindings.push(attr.value);
72
75
  continue;
73
76
  }
74
77
 
75
- if (isFetchableElement && attr.name.startsWith('data-vibe-')) {
76
- const realName = attr.name.slice(10);
77
- if (FETCH_SRC_ATTRS.includes(realName)) {
78
- attributes[realName] = attr.value;
79
- continue;
80
- }
78
+ if (attr.name.startsWith(DEFER_ATTR_PREFIX) && !attr.name.startsWith(INTERNAL_ATTR_PREFIX)) {
79
+ const realName = attr.name.slice(DEFER_ATTR_PREFIX.length);
80
+ attributes[realName] = attr.value;
81
+ (deferredNames ??= new Set()).add(realName);
82
+ continue;
81
83
  }
82
84
 
83
85
  if (isFetchableElement && FETCH_SRC_ATTRS.includes(attr.name)) {
84
86
  BINDING_REGEX.lastIndex = 0;
85
87
  if (BINDING_REGEX.test(attr.value)) {
86
- attributes[attr.name] = attr.value;
87
- (srcToPark ??= []).push([attr.name, attr.value]);
88
+ if (!deferredNames?.has(attr.name)) attributes[attr.name] = attr.value;
89
+ (toPark ??= []).push([attr.name, attr.value]);
88
90
  continue;
89
91
  }
90
92
  }
@@ -110,14 +112,14 @@ const captureAttributeBindings = (element, aliasSet) => {
110
112
  }
111
113
 
112
114
  BINDING_REGEX.lastIndex = 0;
113
- if (BINDING_REGEX.test(attr.value)) {
115
+ if (BINDING_REGEX.test(attr.value) && !deferredNames?.has(attr.name)) {
114
116
  attributes[attr.name] = attr.value;
115
117
  }
116
118
  }
117
119
 
118
- if (srcToPark) {
119
- for (const [name, value] of srcToPark) {
120
- element.setAttribute(`data-vibe-${name}`, value);
120
+ if (toPark) {
121
+ for (const [name, value] of toPark) {
122
+ element.setAttribute(`${DEFER_ATTR_PREFIX}${name}`, value);
121
123
  element.removeAttribute(name);
122
124
  }
123
125
  }
@@ -1,3 +1,7 @@
1
+ import { parkFetchableSrc } from './iteration-utils.js';
2
+ import { HYPERSPEED_DIR, MANIFEST_SUFFIX, COMPONENT_ID_PREFIX } from './constants.js';
3
+
4
+ const CID_SCOPE_BINDING_REGEX = new RegExp(String.raw`@\[${COMPONENT_ID_PREFIX}\d+\.`, 'g');
1
5
  export const buildHyperspeedManifest = (parsedTree) => {
2
6
  const splitByMarkers = (text) => {
3
7
  const regex = /@\[[^\]]+\]/g;
@@ -137,30 +141,30 @@ export const buildManifestCandidatePaths = (pathname, route) => {
137
141
  const prefix = rawSegments
138
142
  .slice(0, routeSegments.length - 1)
139
143
  .map((seg, i) => (routeSegments[i].startsWith(":") ? "$" : seg));
140
- possiblePaths.push(`/vibe-hyperspeed/${[...prefix, "$"].join("/")}.html.manifest.js`);
144
+ possiblePaths.push(`/${HYPERSPEED_DIR}/${[...prefix, "$"].join("/")}.html${MANIFEST_SUFFIX}`);
141
145
  } else if (routeSegments && routeSegments.some((s) => s.startsWith(":"))) {
142
146
  const tokenized = pathSegments.map((seg, i) => {
143
147
  if (!routeSegments[i]?.startsWith(":")) return seg;
144
148
  const dot = seg.indexOf(".");
145
149
  return dot >= 0 ? "$" + seg.slice(dot) : "$";
146
150
  });
147
- possiblePaths.push(`/vibe-hyperspeed/${tokenized.join("/")}.manifest.js`);
151
+ possiblePaths.push(`/${HYPERSPEED_DIR}/${tokenized.join("/")}${MANIFEST_SUFFIX}`);
148
152
  }
149
153
 
150
154
  if (dirSegments.length >= 1) {
151
155
  const subPath = dirSegments.slice(1).join("/");
152
156
  const baseDir = "/" + dirSegments[0];
153
157
  possiblePaths.push(
154
- `${baseDir}/vibe-hyperspeed/${subPath ? subPath + "/" : ""}${fileName}.manifest.js`,
158
+ `${baseDir}/${HYPERSPEED_DIR}/${subPath ? subPath + "/" : ""}${fileName}${MANIFEST_SUFFIX}`,
155
159
  );
156
160
  }
157
161
 
158
- possiblePaths.push(`/vibe-hyperspeed${pagePath}.manifest.js`);
162
+ possiblePaths.push(`/${HYPERSPEED_DIR}${pagePath}${MANIFEST_SUFFIX}`);
159
163
 
160
164
  if (dirSegments.length > 0) {
161
165
  const relativePath = dirSegments.join("/");
162
166
  possiblePaths.push(
163
- `/vibe-hyperspeed/${relativePath}/${fileName}.manifest.js`,
167
+ `/${HYPERSPEED_DIR}/${relativePath}/${fileName}${MANIFEST_SUFFIX}`,
164
168
  );
165
169
  }
166
170
 
@@ -168,7 +172,7 @@ export const buildManifestCandidatePaths = (pathname, route) => {
168
172
  const tokenized = dot >= 0 ? "$" + fileName.slice(dot) : "$";
169
173
  if (tokenized !== fileName) {
170
174
  const dirPrefix = dirSegments.length ? `/${dirSegments.join("/")}` : "";
171
- possiblePaths.push(`/vibe-hyperspeed${dirPrefix}/${tokenized}.manifest.js`);
175
+ possiblePaths.push(`/${HYPERSPEED_DIR}${dirPrefix}/${tokenized}${MANIFEST_SUFFIX}`);
172
176
  }
173
177
 
174
178
  return [...new Set(possiblePaths)];
@@ -236,7 +240,7 @@ export const restoreMarkersFromManifest = (
236
240
  if (hasBindings) {
237
241
  let originalContent = restoration.parsed.join("");
238
242
 
239
- originalContent = originalContent.replace(/@\[_c\d+\./g, "@[this.");
243
+ originalContent = originalContent.replace(CID_SCOPE_BINDING_REGEX, "@[this.");
240
244
 
241
245
  if (
242
246
  element.childNodes.length === 1 &&
@@ -253,7 +257,7 @@ export const restoreMarkersFromManifest = (
253
257
  for (let [attrName, attrValue] of Object.entries(
254
258
  restoration.attributes,
255
259
  )) {
256
- attrValue = attrValue.replace(/@\[_c\d+\./g, "@[this.");
260
+ attrValue = attrValue.replace(CID_SCOPE_BINDING_REGEX, "@[this.");
257
261
 
258
262
  element.setAttribute(attrName, attrValue);
259
263
  }
@@ -298,7 +302,7 @@ export const restoreMarkersFromManifest = (
298
302
  if (hasBindings) {
299
303
  const originalContent = restoration.parsed
300
304
  .join("")
301
- .replace(/@\[_c\d+\./g, "@[this.");
305
+ .replace(CID_SCOPE_BINDING_REGEX, "@[this.");
302
306
 
303
307
  const node = element.childNodes[index];
304
308
  if (index === 0 && Object.keys(tree.children).length === 1) {
@@ -372,6 +376,7 @@ export const restoreMarkersFromManifest = (
372
376
 
373
377
  const tpl = document.createElement("template");
374
378
  tpl.innerHTML = restoration.template;
379
+ parkFetchableSrc(tpl.content);
375
380
  endComment.parentNode.insertBefore(tpl.content, endComment);
376
381
  }
377
382
  }
@@ -1,4 +1,10 @@
1
- import { ITERATION_START_REGEX, CONDITIONAL_START_REGEX } from './constants.js';
1
+ import { parkFetchableSrc } from './iteration-utils.js';
2
+ import {
3
+ ITERATION_START_REGEX,
4
+ CONDITIONAL_START_REGEX,
5
+ DEFER_ATTR_PREFIX,
6
+ FOUC_CLASS_OR_ATTR,
7
+ } from './constants.js';
2
8
 
3
9
  const COMMENT = 8;
4
10
  const ELEMENT = 1;
@@ -146,9 +152,9 @@ const findElseMarker = (nodes, startIdx, endIdx) => {
146
152
  return -1;
147
153
  };
148
154
 
149
- const PRESERVED_ATTRS = new Set(['vibe-fouc']);
150
- const PRESERVED_CLASSES = new Set(['vibe-fouc']);
151
- const isPreservedAttr = (name) => name.startsWith('data-vibe-') || PRESERVED_ATTRS.has(name);
155
+ const PRESERVED_ATTRS = new Set([FOUC_CLASS_OR_ATTR]);
156
+ const PRESERVED_CLASSES = new Set([FOUC_CLASS_OR_ATTR]);
157
+ const isPreservedAttr = (name) => name.startsWith(DEFER_ATTR_PREFIX) || PRESERVED_ATTRS.has(name);
152
158
 
153
159
  const mergeClass = (srcValue, live) => {
154
160
  const tokens = new Set(srcValue.split(/\s+/).filter(Boolean));
@@ -269,9 +275,9 @@ const reconcileRange = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart
269
275
  if (live._vibeProps && src.hasAttribute('src')) {
270
276
  const srcProps = {};
271
277
  for (const a of src.attributes) {
272
- if (a.name === 'vibe-fouc') continue;
278
+ if (a.name === FOUC_CLASS_OR_ATTR) continue;
273
279
  if (a.name === 'class') {
274
- const kept = a.value.split(/\s+/).filter((t) => t && t !== 'vibe-fouc');
280
+ const kept = a.value.split(/\s+/).filter((t) => t && t !== FOUC_CLASS_OR_ATTR);
275
281
  if (kept.length) srcProps.class = kept.join(' ');
276
282
  continue;
277
283
  }
@@ -291,7 +297,7 @@ const reconcileRange = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart
291
297
  for (const [name, value] of Object.entries(srcProps)) {
292
298
  fresh.setAttribute(name, value);
293
299
  }
294
- fresh.setAttribute('vibe-fouc', '');
300
+ fresh.setAttribute(FOUC_CLASS_OR_ATTR, '');
295
301
  const slotHtml = src.innerHTML.trim();
296
302
  fresh._vibeSlotContent = slotHtml;
297
303
  fresh._vibePluginSlot = slotHtml;
@@ -458,6 +464,7 @@ export const reconcile = (target, source) => {
458
464
  const log = { text: 0, attr: 0, insert: 0, remove: 0, replace: 0, changes: [] };
459
465
  const tpl = document.createElement('template');
460
466
  tpl.innerHTML = typeof source === 'string' ? source : source.outerHTML;
467
+ parkFetchableSrc(tpl.content);
461
468
  reconcileChildren(live, [...tpl.content.childNodes], log);
462
469
 
463
470
  return new Promise((resolve) => {
@@ -1,3 +1,4 @@
1
+ import { STAGED_ATTR } from './constants.js';
1
2
  export const liveNode = (node) => {
2
3
  let live = node;
3
4
  while (live && live._vibeReplacedBy) live = live._vibeReplacedBy;
@@ -62,14 +63,14 @@ export const isRemountTrigger = (entry) =>
62
63
  isComponentWrapper(entry.element);
63
64
 
64
65
  export const stageIncoming = (el, newWrapper) => {
65
- if (el.hasAttribute('vibe-staged')) {
66
+ if (el.hasAttribute(STAGED_ATTR)) {
66
67
  newWrapper._vibeCommitOld = el._vibeCommitOld;
67
68
  activeOutgoingRoots.delete(el);
68
- newWrapper.setAttribute('vibe-staged', '');
69
+ newWrapper.setAttribute(STAGED_ATTR, '');
69
70
  el.replaceWith(newWrapper);
70
71
  } else {
71
72
  newWrapper._vibeCommitOld = el;
72
- newWrapper.setAttribute('vibe-staged', '');
73
+ newWrapper.setAttribute(STAGED_ATTR, '');
73
74
  el.removeAttribute('src');
74
75
  el.after(newWrapper);
75
76
  }
@@ -83,7 +84,7 @@ export const commitStaged = (newWrapper) => {
83
84
  replayParked(old);
84
85
  newWrapper._vibeCommitOld = null;
85
86
  }
86
- newWrapper.removeAttribute('vibe-staged');
87
+ newWrapper.removeAttribute(STAGED_ATTR);
87
88
  };
88
89
 
89
90
  export const abandonStaged = (newWrapper) => {
package/runtime/utils.js CHANGED
@@ -1,4 +1,5 @@
1
- import { THIS_PROP_REGEX } from './constants.js';
1
+ import { closestComponentId } from './component-registry.js';
2
+ import { THIS_PROP_REGEX, COMPONENT_ID_PREFIX, COMPONENT_ID_REGEX } from './constants.js';
2
3
  import { recordRead, recordAbsentRead } from './tracking.js';
3
4
  import { reportEvalError } from './debug.js';
4
5
 
@@ -89,11 +90,14 @@ const compileExpression = (normalized) => {
89
90
  };
90
91
 
91
92
  const canonical = normalized.replace(
92
- /('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|\$\['(_c\d+)'\]|((?<=[{,]\s*)[a-zA-Z_$][\w$]*(?=\s*:))|(?<![.\w$'"])[a-zA-Z_$][\w$]*/g,
93
+ new RegExp(
94
+ String.raw`('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|\$\['(${COMPONENT_ID_PREFIX}\d+)'\]|((?<=[{,]\s*)[a-zA-Z_$][\w$]*(?=\s*:))|(?<![.\w$'"])[a-zA-Z_$][\w$]*`,
95
+ 'g',
96
+ ),
93
97
  (match, strLit, bracketCid, objKey) => {
94
98
  if (strLit !== undefined || objKey !== undefined) return match;
95
99
  if (bracketCid !== undefined) return `$[${cidParamFor(bracketCid)}]`;
96
- if (/^_c\d+$/.test(match)) return `$[${cidParamFor(match)}]`;
100
+ if (COMPONENT_ID_REGEX.test(match)) return `$[${cidParamFor(match)}]`;
97
101
  if (match === '$' || locals.has(match) || EVAL_IDENT_EXCLUDE.has(match)) return match;
98
102
  if (!paramSet.has(match)) {
99
103
  paramSet.add(match);
@@ -226,10 +230,10 @@ export const resolveCaseInsensitivePath = (state, path) => {
226
230
  };
227
231
 
228
232
  export const findComponentIdForElement = (element) => {
229
- if (!element || !element.closest) return null;
233
+ if (!element) return null;
230
234
 
231
- const wrapper = element.closest('[data-vibe-component-id]');
232
- if (wrapper) return wrapper.getAttribute('data-vibe-component-id');
235
+ const id = closestComponentId(element);
236
+ if (id) return id;
233
237
  let root = element;
234
238
  while (root.parentNode) root = root.parentNode;
235
239
  if (root._vibeComponentId) return root._vibeComponentId;
@@ -1,6 +1,8 @@
1
- export const COVER_CSS = `[vibe-fouc], .vibe-fouc { visibility: hidden }
2
- [vibe-staged] { display: none }
3
- [vibe-fouc], .vibe-fouc, [vibe-fouc] *, .vibe-fouc * { transition: none !important; animation: none !important }`;
1
+ import { FOUC_CLASS_OR_ATTR, STAGED_ATTR, CSS_COVER_ATTR, CSS_LAYOUT_ATTR } from './constants.js';
2
+
3
+ export const COVER_CSS = `[${FOUC_CLASS_OR_ATTR}], .${FOUC_CLASS_OR_ATTR} { visibility: hidden }
4
+ [${STAGED_ATTR}] { display: none }
5
+ [${FOUC_CLASS_OR_ATTR}], .${FOUC_CLASS_OR_ATTR}, [${FOUC_CLASS_OR_ATTR}] *, .${FOUC_CLASS_OR_ATTR} * { transition: none !important; animation: none !important }`;
4
6
 
5
7
  export const OPTIONAL_MARKER = '/* vibe:optional — everything below is skipped by disableVibeCss */';
6
8
 
@@ -8,21 +10,43 @@ export const LAYOUT_CSS = `${OPTIONAL_MARKER}
8
10
 
9
11
  component, div.component, slot, div.slot { display: contents }`;
10
12
 
11
- const inject = (doc, marker, css) => {
12
- if (doc.querySelector(`style[${marker}]`)) return;
13
+ const sheets = new WeakMap();
14
+
15
+ const sheetRefs = (doc) => {
16
+ let refs = sheets.get(doc);
17
+ if (!refs) {
18
+ refs = {};
19
+ sheets.set(doc, refs);
20
+ }
21
+ return refs;
22
+ };
23
+
24
+ const claim = (doc, key, marker) => {
25
+ const refs = sheetRefs(doc);
26
+ if (refs[key]) return refs[key];
27
+ const served = doc.querySelector(`style[${marker}]`);
28
+ if (served) {
29
+ served.removeAttribute(marker);
30
+ refs[key] = served;
31
+ }
32
+ return refs[key];
33
+ };
34
+
35
+ const inject = (doc, key, marker, css) => {
36
+ if (claim(doc, key, marker)) return;
13
37
  const style = doc.createElement('style');
14
- style.setAttribute(marker, '');
15
38
  style.textContent = css;
16
39
  doc.head.appendChild(style);
40
+ sheetRefs(doc)[key] = style;
17
41
  };
18
42
 
19
43
  export const injectVibeCss = (doc) => {
20
- inject(doc, 'data-vibe-cover', COVER_CSS);
21
- inject(doc, 'data-vibe-css', LAYOUT_CSS);
44
+ inject(doc, 'cover', CSS_COVER_ATTR, COVER_CSS);
45
+ inject(doc, 'layout', CSS_LAYOUT_ATTR, LAYOUT_CSS);
22
46
  };
23
47
 
24
48
  export const removeLayoutCss = (doc) => {
25
- doc.querySelector('style[data-vibe-css]')?.remove();
49
+ claim(doc, 'layout', CSS_LAYOUT_ATTR)?.remove();
26
50
  };
27
51
 
28
52
  export const warnIfCoverDefeated = (doc) => {