@ape-egg/vibe 4.1.4 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vibe
2
2
 
3
- **Version 4.1.4** — 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.1** — 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.4",
3
+ "version": "4.2.1",
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.4';
1
+ export const VERSION = '4.2.1';
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,17 @@ 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,
26
+ DEFER_ATTR_PREFIX,
27
+ INTERNAL_ATTR_PREFIX,
16
28
  } from './constants.js';
17
29
 
18
30
  import * as compiled from './pre-compiled-iterations.js';
@@ -40,10 +52,10 @@ const hasNestedStructures = (tree) => {
40
52
  };
41
53
 
42
54
  const hasComponentSrc = (templateEl) =>
43
- !!templateEl.querySelector?.('component[src], div.component[src]');
55
+ !!templateEl.querySelector?.(COMPONENT_SRC_SELECTOR);
44
56
 
45
57
  const hasInlinedComponentScript = (templateEl) =>
46
- !!templateEl.querySelector?.('script[type="vibe-module"]');
58
+ !!templateEl.querySelector?.(MODULE_SCRIPT_SELECTOR);
47
59
 
48
60
  const canUseBatchRender = (template) =>
49
61
  !globalThis.__vibe?.forceClonePath &&
@@ -61,6 +73,14 @@ const BATCH_ATTR_BINDING_REGEX = new RegExp(
61
73
  String.raw`(\s)([\w-]+)="@\[(${BATCH_BINDING_INNER})\]"`,
62
74
  'g',
63
75
  );
76
+ const DEFERRED_ATTR_REGEX = new RegExp(
77
+ String.raw`(\s)${DEFER_ATTR_PREFIX}(?!${INTERNAL_ATTR_PREFIX.slice(DEFER_ATTR_PREFIX.length)})(?=[\w-])`,
78
+ 'g',
79
+ );
80
+ const realAttrName = (name) =>
81
+ name.startsWith(DEFER_ATTR_PREFIX) && !name.startsWith(INTERNAL_ATTR_PREFIX)
82
+ ? name.slice(DEFER_ATTR_PREFIX.length)
83
+ : name;
64
84
 
65
85
  const mapTagSpans = (html, fn) => {
66
86
  let out = '';
@@ -98,11 +118,13 @@ const isValueStyleAttr = (attrName) =>
98
118
  attrName.startsWith('aria-') ||
99
119
  attrName.startsWith('on');
100
120
 
101
- const CID_ROOT_REGEX =
102
- /('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|(?<![\w$.])_c(\d+)\b/g;
121
+ const CID_ROOT_REGEX = new RegExp(
122
+ String.raw`('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|(?<![\w$.])${COMPONENT_ID_PREFIX}(\d+)\b`,
123
+ 'g',
124
+ );
103
125
 
104
126
  export const canonicalizeComponentIds = (expr) =>
105
- expr.replace(CID_ROOT_REGEX, (m, literal, id) => (literal !== undefined ? literal : `$['_c${id}']`));
127
+ expr.replace(CID_ROOT_REGEX, (m, literal, id) => (literal !== undefined ? literal : `$['${COMPONENT_ID_PREFIX}${id}']`));
106
128
 
107
129
  const nameBindingEmit = (exprSrc) =>
108
130
  '${(()=>{const _v=_e(()=>(' + exprSrc + '));return _v?\' \'+_v+\'=""\':\'\';})()}';
@@ -119,23 +141,24 @@ export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, ancho
119
141
  const attrs = [...el.attributes];
120
142
  for (let a = 0; a < attrs.length; a++) {
121
143
  const attr = attrs[a];
122
- if (!DOM_PROPERTIES.includes(attr.name)) continue;
144
+ const prop = realAttrName(attr.name);
145
+ if (!DOM_PROPERTIES.includes(prop)) continue;
123
146
  const m = attr.value.match(PURE_BINDING_REGEX);
124
147
  if (!m) continue;
125
148
  let expr = m[1];
126
149
  if (componentId) expr = expr.replace(THIS_PROP_REGEX, `$['${componentId}'].$1`);
127
150
  indexes.push(domPropertyWrites.length);
128
- domPropertyWrites.push({ prop: attr.name, expr });
151
+ domPropertyWrites.push({ prop, expr });
129
152
  }
130
153
  if (indexes.length > 0) {
131
- el.setAttribute('data-vibe-batch', indexes.join(','));
154
+ el.setAttribute(BATCH_ATTR, indexes.join(','));
132
155
  }
133
156
  }
134
157
 
135
158
  let templateHtml = tplClone.innerHTML.trim();
136
159
 
137
160
  templateHtml = templateHtml.replace(
138
- /\sdata-vibe-namebind="((?:@\[(?:[^[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\])+)"/g,
161
+ new RegExp(String.raw`\s${NAMEBIND_ATTR}="((?:@\[(?:[^[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\])+)"`, 'g'),
139
162
  (_, bindings) =>
140
163
  bindings.replace(/@\[(?:[^[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\]/g, (b) => ' ' + b + '=""'),
141
164
  );
@@ -160,7 +183,7 @@ export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, ancho
160
183
  let code = escaped;
161
184
 
162
185
  let needsCiWalker = false;
163
- code = mapTagSpans(code, (tag) => tag.replace(BATCH_NAME_BINDING_REGEX, (_, _ws, expr) => {
186
+ code = mapTagSpans(code, (tag) => tag.replace(DEFERRED_ATTR_REGEX, '$1').replace(BATCH_NAME_BINDING_REGEX, (_, _ws, expr) => {
164
187
  let decExpr = decodeEntities(expr);
165
188
  if (decExpr.includes('[') || decExpr.includes('(')) {
166
189
  return nameBindingEmit(decExpr);
@@ -241,7 +264,7 @@ export const releaseOrphanedIterationProps = (nodes) => {
241
264
  el._vibeIterPropIds = null;
242
265
  };
243
266
  free(node);
244
- node.querySelectorAll?.('[data-vibe-iter-prop]').forEach(free);
267
+ eachIterPropIn(node, free);
245
268
  }
246
269
  };
247
270
 
@@ -275,7 +298,7 @@ const resolveSlotContentBindings = (el, scopedState, aliases) => {
275
298
  return undefined;
276
299
  }
277
300
  if (value === undefined) return undefined;
278
- id = `_p${__vibeIterPropCounter++}`;
301
+ id = `${ITER_PROP_PREFIX}${__vibeIterPropCounter++}`;
279
302
  registry[id] = value;
280
303
  idByExpr.set(expr, id);
281
304
  (el._vibeIterPropExprs = el._vibeIterPropExprs || []).push({ id, attrName: null, expr });
@@ -309,7 +332,7 @@ const resolveSlotContentBindings = (el, scopedState, aliases) => {
309
332
  }
310
333
  if (idByExpr.size) {
311
334
  el._vibeSlotContent = rewritten;
312
- el.setAttribute('data-vibe-iter-prop', '');
335
+ markIterProp(el);
313
336
  bumpIterPropGeneration();
314
337
  }
315
338
  };
@@ -330,9 +353,9 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
330
353
  for (let n = 0; n < nodes.length; n++) {
331
354
  const node = nodes[n];
332
355
  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]')];
356
+ const components = node.matches?.(COMPONENT_SRC_SELECTOR)
357
+ ? [node, ...node.querySelectorAll(COMPONENT_SRC_SELECTOR)]
358
+ : [...node.querySelectorAll(COMPONENT_SRC_SELECTOR)];
336
359
  for (let i = 0; i < components.length; i++) {
337
360
  const el = components[i];
338
361
  const attrs = el.attributes;
@@ -346,7 +369,7 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
346
369
  /\bthis\b/.test(expr) ||
347
370
  (aliases && extractDependencies(expr).some((d) => aliases.has(d)));
348
371
  if (!usesLocalScope) {
349
- el.setAttribute('data-vibe-iter-prop', '');
372
+ markIterProp(el);
350
373
  bumpIterPropGeneration();
351
374
  el._vibeIterPropExprs = el._vibeIterPropExprs || [];
352
375
  continue;
@@ -355,10 +378,10 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
355
378
  const value = evalInScope(expr, scopedState, el);
356
379
  if (value === undefined) continue;
357
380
  const registry = ensureIterPropsRegistry();
358
- const id = `_p${__vibeIterPropCounter++}`;
381
+ const id = `${ITER_PROP_PREFIX}${__vibeIterPropCounter++}`;
359
382
  registry[id] = value;
360
- el.setAttribute(attr.name, `@[window.__vibe.iterProps.${id}]`);
361
- el.setAttribute('data-vibe-iter-prop', '');
383
+ el.setAttribute(attr.name, `@[window.${ITER_PROP_PATH}.${id}]`);
384
+ markIterProp(el);
362
385
  bumpIterPropGeneration();
363
386
  (el._vibeIterPropIds = el._vibeIterPropIds || []).push(id);
364
387
  (el._vibeIterPropExprs = el._vibeIterPropExprs || []).push({ id, attrName: attr.name, expr });
@@ -401,7 +424,7 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
401
424
  return changed;
402
425
  };
403
426
 
404
- const REGISTRY_SLOT_REGEX = /__vibe\.iterProps\.(_p\d+)/;
427
+ const REGISTRY_SLOT_REGEX = new RegExp(`${ITER_PROP_PATH.replace('.', String.raw`\.`)}\\.(${ITER_PROP_PREFIX}\\d+)`);
405
428
  const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope, changedSlots) => {
406
429
  if (!tree) return;
407
430
  if (tree.type === 'iteration') {
@@ -435,12 +458,12 @@ const applyDomPropertyWrites = (instances, array, state, itemAlias, indexAlias,
435
458
  if (!root || root.nodeType !== 1) continue;
436
459
  const localState = { ...state, [itemAlias]: array[i], [indexAlias]: i };
437
460
  const tagged = [];
438
- if (root.matches?.('[data-vibe-batch]')) tagged.push(root);
439
- const found = root.querySelectorAll?.('[data-vibe-batch]');
461
+ if (root.matches?.(`[${BATCH_ATTR}]`)) tagged.push(root);
462
+ const found = root.querySelectorAll?.(`[${BATCH_ATTR}]`);
440
463
  if (found) for (let f = 0; f < found.length; f++) tagged.push(found[f]);
441
464
  for (let t = 0; t < tagged.length; t++) {
442
465
  const el = tagged[t];
443
- const indexes = el.getAttribute('data-vibe-batch').split(',');
466
+ const indexes = el.getAttribute(BATCH_ATTR).split(',');
444
467
  for (let k = 0; k < indexes.length; k++) {
445
468
  const { prop, expr } = domPropertyWrites[indexes[k] | 0];
446
469
  const value = evalInScope(expr, localState, el);
@@ -458,7 +481,7 @@ const applyDomPropertyWrites = (instances, array, state, itemAlias, indexAlias,
458
481
  el.removeAttribute(prop);
459
482
  }
460
483
  }
461
- el.removeAttribute('data-vibe-batch');
484
+ el.removeAttribute(BATCH_ATTR);
462
485
  }
463
486
  }
464
487
  };
@@ -520,6 +543,7 @@ const renderBatch = (iterationNode, array, state, parent, endComment, parentScop
520
543
  instances[i] = { element: kids[i], item: array[i], index: i };
521
544
  }
522
545
 
546
+ ingestComponentIds(frag);
523
547
  parent.insertBefore(frag, endComment);
524
548
  iterationNode.runtime.instances = instances;
525
549
 
@@ -628,15 +652,15 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
628
652
  return cloned;
629
653
  };
630
654
 
631
- const COMPONENT_ID = /^_c\d+$/;
655
+ const COMPONENT_ID = COMPONENT_ID_REGEX;
632
656
 
633
657
  const isolateInlinedComponentIds = (container) => {
634
658
  const remap = new Map();
635
- for (const el of container.querySelectorAll('[data-vibe-component-id]')) {
636
- const oldId = el.getAttribute('data-vibe-component-id');
659
+ for (const el of container.querySelectorAll(`[${COMPONENT_ID_ATTR}]`)) {
660
+ const oldId = el.getAttribute(COMPONENT_ID_ATTR);
637
661
  if (!COMPONENT_ID.test(oldId)) continue;
638
662
  if (!remap.has(oldId)) remap.set(oldId, generateComponentId());
639
- el.setAttribute('data-vibe-component-id', remap.get(oldId));
663
+ el.setAttribute(COMPONENT_ID_ATTR, remap.get(oldId));
640
664
  }
641
665
  if (!remap.size) return false;
642
666
 
@@ -652,13 +676,13 @@ const isolateInlinedComponentIds = (container) => {
652
676
  const walk = (node) => {
653
677
  if (node.nodeType === 1) {
654
678
  for (const attr of node.attributes) {
655
- if (attr.value.includes('_c')) {
679
+ if (attr.value.includes(COMPONENT_ID_PREFIX)) {
656
680
  const next = rewrite(attr.value);
657
681
  if (next !== attr.value) attr.value = next;
658
682
  }
659
683
  }
660
684
  for (const child of node.childNodes) walk(child);
661
- } else if (node.nodeType === 3 && node.textContent.includes('_c')) {
685
+ } else if (node.nodeType === 3 && node.textContent.includes(COMPONENT_ID_PREFIX)) {
662
686
  node.textContent = rewrite(node.textContent);
663
687
  }
664
688
  };
@@ -684,7 +708,7 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, c
684
708
  }
685
709
  }
686
710
 
687
- const components = parseContainer.querySelectorAll('component[src], div.component[src]');
711
+ const components = parseContainer.querySelectorAll(COMPONENT_SRC_SELECTOR);
688
712
  const scopeKeys = overlayKeysOf(scopedState);
689
713
  for (let i = 0; i < components.length; i++) {
690
714
  const el = components[i];
@@ -717,6 +741,8 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, c
717
741
  hydrate(affectedElements, scopedState);
718
742
  }
719
743
 
744
+ ingestComponentIds(parseContainer);
745
+
720
746
  return {
721
747
  element: firstElement,
722
748
  tree,
@@ -1179,6 +1205,7 @@ const renderInstances = (iterationNode, array, state, manifest, parentScope) =>
1179
1205
  clonedNodes: built.clonedNodes, scopedState: built.scopedState,
1180
1206
  });
1181
1207
  }
1208
+ ingestComponentIds(frag);
1182
1209
  parent.insertBefore(frag, endComment);
1183
1210
  iterationNode.runtime.instances = instances;
1184
1211
  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) => {