@ape-egg/vibe 1.9.9 → 2.0.5
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/CHANGELOG.md +84 -0
- package/README.md +1 -1
- package/ROADMAP.md +45 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +1 -1
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +100 -13
- package/compiler/src/compiler/iteration_optimizer.rs +188 -112
- package/compiler/src/compiler/manifest_builder.rs +5 -9
- package/compiler/src/parser/html.rs +194 -31
- package/package.json +1 -1
- package/runtime/cleanup.js +12 -3
- package/runtime/component.js +130 -43
- package/runtime/conditionals.js +16 -9
- package/runtime/hydrate.js +12 -3
- package/runtime/index.js +23 -2
- package/runtime/iterate.js +112 -22
- package/runtime/loop-scope.js +49 -3
- package/runtime/parse.js +8 -0
- package/runtime/pre-compiled-manifest.js +56 -155
- package/runtime/utils.js +12 -2
package/runtime/cleanup.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { debugLog } from './debug.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
PHASE_READY,
|
|
4
|
+
FOUC_CLASS_OR_ATTR,
|
|
5
|
+
DEHYDRATE_CLASS_OR_ATTR,
|
|
6
|
+
NON_REACTIVE_ELEMENTS,
|
|
7
|
+
} from './constants.js';
|
|
3
8
|
|
|
4
9
|
/**
|
|
5
10
|
* Check if all Vibe processing is complete and cleanup can run
|
|
@@ -14,12 +19,16 @@ export const shouldCleanup = (rootElement) => {
|
|
|
14
19
|
return false;
|
|
15
20
|
}
|
|
16
21
|
|
|
17
|
-
// 2. Check for unhydrated bindings (literal @[...] in DOM, excluding dehydrated
|
|
22
|
+
// 2. Check for unhydrated bindings (literal @[...] in DOM, excluding dehydrated
|
|
23
|
+
// and non-reactive elements — hydrate never touches those, so a literal inside
|
|
24
|
+
// them is final content, not pending work)
|
|
18
25
|
const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_TEXT, {
|
|
19
26
|
acceptNode(node) {
|
|
20
|
-
// Check if this text node is inside a dehydrated element
|
|
21
27
|
let parent = node.parentElement;
|
|
22
28
|
while (parent && parent !== rootElement) {
|
|
29
|
+
if (NON_REACTIVE_ELEMENTS.includes(parent.nodeName)) {
|
|
30
|
+
return NodeFilter.FILTER_REJECT;
|
|
31
|
+
}
|
|
23
32
|
if (parent.hasAttribute(DEHYDRATE_CLASS_OR_ATTR) || parent.classList?.contains(DEHYDRATE_CLASS_OR_ATTR)) {
|
|
24
33
|
return NodeFilter.FILTER_REJECT; // Skip dehydrated content
|
|
25
34
|
}
|
package/runtime/component.js
CHANGED
|
@@ -98,9 +98,131 @@ export const collectComponentIds = (node, into = new Set()) => {
|
|
|
98
98
|
return into;
|
|
99
99
|
};
|
|
100
100
|
|
|
101
|
+
// Prepare a component <script> body for execution through the injected
|
|
102
|
+
// component() path: strip the `import component from '...'` line (the
|
|
103
|
+
// function is passed in as a parameter) and rewrite any remaining static
|
|
104
|
+
// imports to awaited dynamic imports. Shared by the fetch path
|
|
105
|
+
// (processSingle) and the compiled-page path (executeCompiledComponentScripts).
|
|
106
|
+
const transformScriptContent = (rawContent) => {
|
|
107
|
+
// Strip `import component from '...'` — Vibe injects the contextual
|
|
108
|
+
// component() function as a parameter (it needs access to the temp DOM)
|
|
109
|
+
let content = rawContent.replace(
|
|
110
|
+
/import\s+component\s+from\s+['"][^'"]+['"];?\s*/g,
|
|
111
|
+
''
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// Check for remaining imports that need rewriting
|
|
115
|
+
const hasImports = /import\s/.test(content);
|
|
116
|
+
|
|
117
|
+
if (hasImports) {
|
|
118
|
+
// Rewrite remaining imports to dynamic await import()
|
|
119
|
+
// Order matters: combined → default → named → namespace → side-effect (most specific first)
|
|
120
|
+
content = content.replace(
|
|
121
|
+
/import\s+(\w+)\s*,\s*\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
122
|
+
'const __m_$1 = await import($3); const $1 = __m_$1.default; const {$2} = __m_$1;'
|
|
123
|
+
);
|
|
124
|
+
content = content.replace(
|
|
125
|
+
/import\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
126
|
+
'const $1 = (await import($2)).default;'
|
|
127
|
+
);
|
|
128
|
+
content = content.replace(
|
|
129
|
+
/import\s+\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
130
|
+
'const {$1} = await import($2);'
|
|
131
|
+
);
|
|
132
|
+
content = content.replace(
|
|
133
|
+
/import\s+\*\s+as\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
134
|
+
'const $1 = await import($2);'
|
|
135
|
+
);
|
|
136
|
+
content = content.replace(
|
|
137
|
+
/import\s+(['"][^'"]+['"])\s*;?/g,
|
|
138
|
+
'await import($1);'
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { content, hasImports };
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Compiled pages inline `<component src>` content at build time, and the
|
|
146
|
+
// compiler neuters each component script to type="vibe-module" so the browser
|
|
147
|
+
// does NOT execute it as a native page module — native timing is wrong
|
|
148
|
+
// (pre-boot: `$` is the placeholder, state registered after boot never merges
|
|
149
|
+
// into the live proxy). This executes those scripts through the same
|
|
150
|
+
// injected-component() path processSingle uses for fetched scripts: one
|
|
151
|
+
// pipeline, identical semantics in both modes. Scripts stay in the DOM
|
|
152
|
+
// (inert) so the manifest's childNodes indices keep matching the page.
|
|
153
|
+
//
|
|
154
|
+
// Returns a Promise when any script is async (has imports) — the caller
|
|
155
|
+
// gates `ready` on it — or null when everything ran synchronously.
|
|
156
|
+
export const executeCompiledComponentScripts = () => {
|
|
157
|
+
const scripts = document.querySelectorAll('script[type="vibe-module"]');
|
|
158
|
+
if (!scripts.length) return null;
|
|
159
|
+
|
|
160
|
+
// Build-time tagging already assigned _cN ids to wrappers; advance the
|
|
161
|
+
// runtime counter past them so freshly generated ids never collide.
|
|
162
|
+
document.querySelectorAll('[data-vibe-component-id]').forEach((el) => {
|
|
163
|
+
const m = el.getAttribute('data-vibe-component-id')?.match(/^_c(\d+)$/);
|
|
164
|
+
if (m) componentCounter = Math.max(componentCounter, Number(m[1]) + 1);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const asyncTasks = [];
|
|
168
|
+
const claimed = new Set();
|
|
169
|
+
|
|
170
|
+
for (const script of scripts) {
|
|
171
|
+
// Parity with the fetch path: dehydrated components never execute
|
|
172
|
+
if (script.closest(`[${DEHYDRATE_CLASS_OR_ATTR}], .${DEHYDRATE_CLASS_OR_ATTR}`)) continue;
|
|
173
|
+
|
|
174
|
+
const rawContent = script.textContent?.trim() || '';
|
|
175
|
+
if (!rawContent) continue;
|
|
176
|
+
|
|
177
|
+
// The build tagged each component wrapper with its deterministic id —
|
|
178
|
+
// the same id the stamped bindings reference. First script in a wrapper
|
|
179
|
+
// claims it; additional scripts get fresh ids (mirrors the per-script
|
|
180
|
+
// ids of the fetch path).
|
|
181
|
+
const wrapper = script.closest('[data-vibe-component-id]');
|
|
182
|
+
let componentId = wrapper?.getAttribute('data-vibe-component-id');
|
|
183
|
+
if (!componentId || claimed.has(componentId)) componentId = generateComponentId();
|
|
184
|
+
claimed.add(componentId);
|
|
185
|
+
|
|
186
|
+
const { content, hasImports } = transformScriptContent(rawContent);
|
|
187
|
+
|
|
188
|
+
const componentFn = (state) => {
|
|
189
|
+
if (!window.__vibeComponents) window.__vibeComponents = {};
|
|
190
|
+
window.__vibeComponents[componentId] = state;
|
|
191
|
+
if (window.$) window.$[componentId] = state;
|
|
192
|
+
return componentId;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
runComponentCleanups(componentId);
|
|
196
|
+
const scopedDollar = createScopedDollar(componentId);
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
if (hasImports) {
|
|
200
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
201
|
+
asyncTasks.push(new AsyncFunction('$', 'component', content)(scopedDollar, componentFn));
|
|
202
|
+
} else {
|
|
203
|
+
new Function('$', 'component', content)(scopedDollar, componentFn);
|
|
204
|
+
}
|
|
205
|
+
} catch (e) {
|
|
206
|
+
console.warn('[vibe] Failed to execute component script:', e);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return asyncTasks.length ? Promise.all(asyncTasks) : null;
|
|
211
|
+
};
|
|
212
|
+
|
|
101
213
|
// Track pending fetches to cancel them if element is removed
|
|
102
214
|
const pendingFetches = new WeakMap(); // element → AbortController
|
|
103
215
|
|
|
216
|
+
// Component HTML is parsed (and prop/slot-transformed) while its @[...]
|
|
217
|
+
// bindings are still literal text. Parsing in the live document lets the
|
|
218
|
+
// browser act on those literals mid-parse — Chrome logs "The specified value
|
|
219
|
+
// ... cannot be parsed" for a typed input's value="@[...]". An inert document
|
|
220
|
+
// (no browsing context) parses identical DOM without a console to complain to;
|
|
221
|
+
// nodes are auto-adopted into the live document on insertion.
|
|
222
|
+
let inertDocument;
|
|
223
|
+
const createDetached = (tagName) =>
|
|
224
|
+
(inertDocument ??= document.implementation.createHTMLDocument('')).createElement(tagName);
|
|
225
|
+
|
|
104
226
|
// Cancel a pending fetch for a component element
|
|
105
227
|
export const abortComponentFetch = (element) => {
|
|
106
228
|
const controller = pendingFetches.get(element);
|
|
@@ -273,7 +395,7 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
|
|
|
273
395
|
// or trigger a full re-mount (new componentIds).
|
|
274
396
|
export const renderComponentTemplate = (rawHtml, props = {}, slotHtml = '', options = {}) => {
|
|
275
397
|
const { componentIds = [] } = options;
|
|
276
|
-
const temp =
|
|
398
|
+
const temp = createDetached('div');
|
|
277
399
|
temp.innerHTML = rawHtml;
|
|
278
400
|
|
|
279
401
|
const idsToReuse = [...componentIds];
|
|
@@ -318,7 +440,7 @@ const processSingle = (el, debug) => {
|
|
|
318
440
|
.then((r) => r.text())
|
|
319
441
|
.then((html) => {
|
|
320
442
|
// Parse HTML in temporary container to process component scripts
|
|
321
|
-
const temp =
|
|
443
|
+
const temp = createDetached('div');
|
|
322
444
|
temp.innerHTML = html;
|
|
323
445
|
|
|
324
446
|
// Process any <script type="module"> elements
|
|
@@ -343,43 +465,10 @@ const processSingle = (el, debug) => {
|
|
|
343
465
|
let firstComponentId = null;
|
|
344
466
|
|
|
345
467
|
for (const script of moduleScripts) {
|
|
346
|
-
|
|
347
|
-
if (!
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
// component() function as a parameter (it needs access to the temp DOM)
|
|
351
|
-
scriptContent = scriptContent.replace(
|
|
352
|
-
/import\s+component\s+from\s+['"][^'"]+['"];?\s*/g,
|
|
353
|
-
''
|
|
354
|
-
);
|
|
355
|
-
|
|
356
|
-
// Check for remaining imports that need rewriting
|
|
357
|
-
const hasImports = /import\s/.test(scriptContent);
|
|
358
|
-
|
|
359
|
-
if (hasImports) {
|
|
360
|
-
// Rewrite remaining imports to dynamic await import()
|
|
361
|
-
// Order matters: combined → default → named → namespace → side-effect (most specific first)
|
|
362
|
-
scriptContent = scriptContent.replace(
|
|
363
|
-
/import\s+(\w+)\s*,\s*\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
364
|
-
'const __m_$1 = await import($3); const $1 = __m_$1.default; const {$2} = __m_$1;'
|
|
365
|
-
);
|
|
366
|
-
scriptContent = scriptContent.replace(
|
|
367
|
-
/import\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
368
|
-
'const $1 = (await import($2)).default;'
|
|
369
|
-
);
|
|
370
|
-
scriptContent = scriptContent.replace(
|
|
371
|
-
/import\s+\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
372
|
-
'const {$1} = await import($2);'
|
|
373
|
-
);
|
|
374
|
-
scriptContent = scriptContent.replace(
|
|
375
|
-
/import\s+\*\s+as\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
376
|
-
'const $1 = await import($2);'
|
|
377
|
-
);
|
|
378
|
-
scriptContent = scriptContent.replace(
|
|
379
|
-
/import\s+(['"][^'"]+['"])\s*;?/g,
|
|
380
|
-
'await import($1);'
|
|
381
|
-
);
|
|
382
|
-
}
|
|
468
|
+
const rawContent = script.textContent?.trim() || '';
|
|
469
|
+
if (!rawContent) continue;
|
|
470
|
+
|
|
471
|
+
const { content: scriptContent, hasImports } = transformScriptContent(rawContent);
|
|
383
472
|
|
|
384
473
|
// Reuse component ID from HMR if available, otherwise generate new
|
|
385
474
|
const reuseIds = el._vibeReuseComponentIds;
|
|
@@ -454,9 +543,7 @@ const processSingle = (el, debug) => {
|
|
|
454
543
|
if (el.parentNode) {
|
|
455
544
|
// Create clean wrapper element (preserve tag type: component or div.component)
|
|
456
545
|
const newWrapper =
|
|
457
|
-
el.tagName === 'DIV'
|
|
458
|
-
? document.createElement('div')
|
|
459
|
-
: document.createElement('component');
|
|
546
|
+
el.tagName === 'DIV' ? createDetached('div') : createDetached('component');
|
|
460
547
|
|
|
461
548
|
if (el.tagName === 'DIV') {
|
|
462
549
|
newWrapper.className = 'component';
|
|
@@ -476,7 +563,7 @@ const processSingle = (el, debug) => {
|
|
|
476
563
|
// detached `<component src>` to the new wrapper. The detached element
|
|
477
564
|
// would otherwise trigger releaseOrphanedIterationProps and free the
|
|
478
565
|
// registry slots that the inlined template's bindings still reference,
|
|
479
|
-
// causing every `@[window.
|
|
566
|
+
// causing every `@[window.__vibeiterprops._pN]` to resolve to undefined
|
|
480
567
|
// on the next hydrate.
|
|
481
568
|
if (el._vibeIterPropIds) {
|
|
482
569
|
newWrapper._vibeIterPropIds = el._vibeIterPropIds;
|
package/runtime/conditionals.js
CHANGED
|
@@ -263,18 +263,25 @@ const unmountBranch = (node, manifest) => {
|
|
|
263
263
|
}
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
//
|
|
266
|
+
// The conditional owns everything between its comments. That's more than
|
|
267
|
+
// activeInstance.nodes: nested directives at the branch's top level (each
|
|
268
|
+
// rows, deeper if branches) insert their rendered output between their own
|
|
269
|
+
// comments after the branch mounts, so it never appears in the original
|
|
270
|
+
// clonedNodes list. Sweep the live range (same pattern as iteration
|
|
271
|
+
// teardown), collecting componentIds BEFORE detaching so we can check
|
|
267
272
|
// after removal whether any live DOM still holds them.
|
|
273
|
+
const { startComment, endComment } = node.meta;
|
|
268
274
|
const ids = new Set();
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
branchNodeRegistry.delete(
|
|
274
|
-
if (
|
|
275
|
-
|
|
275
|
+
let current = startComment.nextSibling;
|
|
276
|
+
while (current && current !== endComment) {
|
|
277
|
+
const next = current.nextSibling;
|
|
278
|
+
collectComponentIds(current, ids);
|
|
279
|
+
branchNodeRegistry.delete(current);
|
|
280
|
+
if (current.parentNode) {
|
|
281
|
+
current.parentNode.removeChild(current);
|
|
276
282
|
}
|
|
277
|
-
|
|
283
|
+
current = next;
|
|
284
|
+
}
|
|
278
285
|
|
|
279
286
|
// CLEANUP OF CURRENT STATE
|
|
280
287
|
releaseOrphanedComponentState(ids);
|
package/runtime/hydrate.js
CHANGED
|
@@ -92,9 +92,18 @@ export default (affected, state, manifest = {}, oldState = {}) => {
|
|
|
92
92
|
const expr = isPureBinding[1];
|
|
93
93
|
const value = evalInScope(expr, effectiveState, element);
|
|
94
94
|
if (element[attrName] !== value) element[attrName] = value;
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
if (attrName === 'value') {
|
|
96
|
+
if (value !== undefined && value !== null) {
|
|
97
|
+
const str = String(value);
|
|
98
|
+
if (element.getAttribute(attrName) !== str) element.setAttribute(attrName, str);
|
|
99
|
+
}
|
|
100
|
+
} else if (value) {
|
|
101
|
+
// checked/selected are boolean — the truthful attribute form is
|
|
102
|
+
// presence (empty) when truthy, absence when falsy. Stringifying
|
|
103
|
+
// would leave checked="false", which is "checked" to CSS and HTML.
|
|
104
|
+
if (element.getAttribute(attrName) !== '') element.setAttribute(attrName, '');
|
|
105
|
+
} else if (element.hasAttribute(attrName)) {
|
|
106
|
+
element.removeAttribute(attrName);
|
|
98
107
|
}
|
|
99
108
|
} else if (!isValueAttr && isPureBinding) {
|
|
100
109
|
// Boolean-like attributes: add or remove based on truthiness.
|
package/runtime/index.js
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
PHASE_READY,
|
|
24
24
|
DEHYDRATE_CLASS_OR_ATTR,
|
|
25
25
|
} from './constants.js';
|
|
26
|
-
import { processComponent, abortComponentFetch, collectComponentIds, releaseOrphanedComponentState, renderComponentTemplate } from './component.js';
|
|
26
|
+
import { processComponent, abortComponentFetch, collectComponentIds, releaseOrphanedComponentState, renderComponentTemplate, executeCompiledComponentScripts } from './component.js';
|
|
27
27
|
import { debugLog } from './debug.js';
|
|
28
28
|
import { shouldCleanup, cleanup } from './cleanup.js';
|
|
29
29
|
import { reconcile } from './reconcile.js';
|
|
@@ -706,6 +706,18 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
706
706
|
// first conditional/binding eval.
|
|
707
707
|
if (typeof window !== 'undefined') window.$ = $;
|
|
708
708
|
|
|
709
|
+
// Compiled pages: execute build-inlined component scripts (neutered to
|
|
710
|
+
// type="vibe-module" by the compiler) through the runtime's component-script
|
|
711
|
+
// pipeline — same injected component(), same scoped `$`, same import
|
|
712
|
+
// rewriting as fetched scripts. Runs after `window.$` is live so
|
|
713
|
+
// `const id = component(state); $[id].x = ...` captures the reactive proxy,
|
|
714
|
+
// and before initial hydration so synchronous scripts' state is already
|
|
715
|
+
// registered when `this.` bindings first evaluate. Async scripts gate
|
|
716
|
+
// `ready` via compiledScriptsDone below.
|
|
717
|
+
let compiledScriptsDone = true;
|
|
718
|
+
const compiledScriptsPending = executeCompiledComponentScripts();
|
|
719
|
+
if (compiledScriptsPending) compiledScriptsDone = false;
|
|
720
|
+
|
|
709
721
|
// Initial hydration - pass plain values so iteration can do reference comparison
|
|
710
722
|
const initialState = extractPlainValue($);
|
|
711
723
|
const affectedElements = affected(parsedTree, initialState, initialState);
|
|
@@ -1125,7 +1137,7 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
1125
1137
|
|
|
1126
1138
|
// Check if cleanup should run
|
|
1127
1139
|
const checkCleanup = () => {
|
|
1128
|
-
if (cleanupExecuted || isCompiling) return;
|
|
1140
|
+
if (cleanupExecuted || isCompiling || !compiledScriptsDone) return;
|
|
1129
1141
|
|
|
1130
1142
|
// Check for pending mutations first
|
|
1131
1143
|
const pendingMutations = observer ? observer.takeRecords() : [];
|
|
@@ -1157,6 +1169,15 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
1157
1169
|
// Register hook to check for cleanup readiness after each mutation batch
|
|
1158
1170
|
hooks.afterDomMutation.push(checkCleanup);
|
|
1159
1171
|
|
|
1172
|
+
// Async compiled component scripts (imports) finish after boot — unlock the
|
|
1173
|
+
// ready gate and re-check once their state has merged into `$`.
|
|
1174
|
+
if (compiledScriptsPending) {
|
|
1175
|
+
compiledScriptsPending.then(() => {
|
|
1176
|
+
compiledScriptsDone = true;
|
|
1177
|
+
checkCleanup();
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1160
1181
|
// Process component elements after initialization - MutationObserver will handle hydration
|
|
1161
1182
|
componentProcessingStarted = true;
|
|
1162
1183
|
|
package/runtime/iterate.js
CHANGED
|
@@ -87,6 +87,40 @@ const BATCH_ATTR_BINDING_REGEX = new RegExp(
|
|
|
87
87
|
'g',
|
|
88
88
|
);
|
|
89
89
|
|
|
90
|
+
// Apply fn to tag spans only (`<el ...>`, quote-aware so a `>` inside an
|
|
91
|
+
// attribute value doesn't end the span), leaving text spans and comments
|
|
92
|
+
// untouched. Used to scope the name-binding rewrite to positions where a
|
|
93
|
+
// name binding can actually occur.
|
|
94
|
+
const mapTagSpans = (html, fn) => {
|
|
95
|
+
let out = '';
|
|
96
|
+
let i = 0;
|
|
97
|
+
while (i < html.length) {
|
|
98
|
+
const lt = html.indexOf('<', i);
|
|
99
|
+
if (lt === -1) {
|
|
100
|
+
out += html.slice(i);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
out += html.slice(i, lt);
|
|
104
|
+
let j = lt + 1;
|
|
105
|
+
let quote = null;
|
|
106
|
+
while (j < html.length) {
|
|
107
|
+
const c = html[j];
|
|
108
|
+
if (quote) {
|
|
109
|
+
if (c === quote) quote = null;
|
|
110
|
+
} else if (c === '"' || c === "'") {
|
|
111
|
+
quote = c;
|
|
112
|
+
} else if (c === '>') {
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
j++;
|
|
116
|
+
}
|
|
117
|
+
const span = html.slice(lt, Math.min(j + 1, html.length));
|
|
118
|
+
out += /^<\/?[a-zA-Z]/.test(span) ? fn(span) : span;
|
|
119
|
+
i = j + 1;
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
};
|
|
123
|
+
|
|
90
124
|
// Whether a hydrate'd attribute is a "value-style" string attr (kept verbatim)
|
|
91
125
|
// rather than a boolean-coerced attr (added/removed by truthiness). Mirrors
|
|
92
126
|
// the predicate hydrate.js uses, so batch and clone classify identically.
|
|
@@ -126,6 +160,17 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
|
|
|
126
160
|
const componentId = anchorEl ? findComponentIdForElement(anchorEl) : null;
|
|
127
161
|
const domPropertyWrites = [];
|
|
128
162
|
|
|
163
|
+
// Trim binding-bearing text nodes — the clone path writes the interpolated
|
|
164
|
+
// text node content trimmed (hydrate.js), so the batch template must not
|
|
165
|
+
// carry the author's indentation around a binding that sits on its own line.
|
|
166
|
+
const textWalker = document.createTreeWalker(tplClone, NodeFilter.SHOW_TEXT);
|
|
167
|
+
while (textWalker.nextNode()) {
|
|
168
|
+
const textNode = textWalker.currentNode;
|
|
169
|
+
if (textNode.textContent.includes('@[')) {
|
|
170
|
+
textNode.textContent = textNode.textContent.trim();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
129
174
|
const allEls = tplClone.querySelectorAll('*');
|
|
130
175
|
for (let n = 0; n < allEls.length; n++) {
|
|
131
176
|
const el = allEls[n];
|
|
@@ -167,7 +212,10 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
|
|
|
167
212
|
|
|
168
213
|
// Name bindings: `<el @[expr]>` — emit ` resolvedName=""` when truthy, else
|
|
169
214
|
// emit nothing. The lookahead `(?=[\s/>])` distinguishes name-position
|
|
170
|
-
// bindings from attribute-value-position bindings (which are followed by `=`)
|
|
215
|
+
// bindings from attribute-value-position bindings (which are followed by `=`)
|
|
216
|
+
// — but a TEXT-position binding on its own line is also whitespace-bounded,
|
|
217
|
+
// so the pass runs only over tag spans (mapTagSpans): name bindings can only
|
|
218
|
+
// exist inside a tag.
|
|
171
219
|
//
|
|
172
220
|
// HTML parses attribute names lowercase, so a binding like
|
|
173
221
|
// `<icon @[attrName]>` arrives here as `@[attrname]` and a dotted form like
|
|
@@ -182,7 +230,7 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
|
|
|
182
230
|
// Bracket / call expressions pass through unchanged — they need a real
|
|
183
231
|
// evaluator and aren't worth special-casing here.
|
|
184
232
|
let needsCiWalker = false;
|
|
185
|
-
code = code.replace(BATCH_NAME_BINDING_REGEX, (_, _ws, expr) => {
|
|
233
|
+
code = mapTagSpans(code, (tag) => tag.replace(BATCH_NAME_BINDING_REGEX, (_, _ws, expr) => {
|
|
186
234
|
let decExpr = decodeEntities(expr);
|
|
187
235
|
if (decExpr.includes('[') || decExpr.includes('(')) {
|
|
188
236
|
return '${(' + decExpr + ') ? \' \' + (' + decExpr + ') + \'=""\' : \'\'}';
|
|
@@ -203,7 +251,7 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
|
|
|
203
251
|
'${(()=>{const _v=_walkCi(' + ciHead + ',' + tailJSON +
|
|
204
252
|
');return _v?\' \'+_v+\'=""\':\'\';})()}'
|
|
205
253
|
);
|
|
206
|
-
});
|
|
254
|
+
}));
|
|
207
255
|
|
|
208
256
|
// Pure-binding attributes (`attr="@[expr]"`) — classify by attribute name:
|
|
209
257
|
// DOM property → emit attribute (post-stamp also writes the property)
|
|
@@ -266,23 +314,31 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
|
|
|
266
314
|
// reachable from binding expressions because `window` is in evalInScope's
|
|
267
315
|
// known-globals list. Each entry is freed when the owning component element
|
|
268
316
|
// is detached (see releaseOrphanedIterationProps).
|
|
317
|
+
//
|
|
318
|
+
// The name is intentionally all-lowercase. resolveIterationComponentProps
|
|
319
|
+
// injects `@[window.__vibeiterprops._pN]` into the component's bindings, and
|
|
320
|
+
// prop substitution carries that accessor into the template's own bindings —
|
|
321
|
+
// including name-bindings (`<icon @[props.element]>`). HTML lowercases
|
|
322
|
+
// attribute names, so a camelCase accessor would arrive at hydrate as
|
|
323
|
+
// `window.__vibeiterprops` and resolve to undefined, silently dropping the
|
|
324
|
+
// attribute. Keeping the global lowercase makes it survive that normalization.
|
|
269
325
|
let __vibeIterPropCounter = 0;
|
|
270
326
|
const ensureIterPropsRegistry = () => {
|
|
271
|
-
if (!window.
|
|
272
|
-
return window.
|
|
327
|
+
if (!window.__vibeiterprops) window.__vibeiterprops = {};
|
|
328
|
+
return window.__vibeiterprops;
|
|
273
329
|
};
|
|
274
330
|
|
|
275
331
|
// Walk a removed subtree and free any iteration-prop registry slots stashed
|
|
276
332
|
// on `<component>` elements inside it. Called from the mutation-observer
|
|
277
333
|
// cleanup path after DOM detachment.
|
|
278
334
|
export const releaseOrphanedIterationProps = (nodes) => {
|
|
279
|
-
if (!window.
|
|
335
|
+
if (!window.__vibeiterprops) return;
|
|
280
336
|
for (const node of nodes) {
|
|
281
337
|
if (node.nodeType !== 1) continue;
|
|
282
338
|
const free = (el) => {
|
|
283
339
|
const ids = el._vibeIterPropIds;
|
|
284
340
|
if (!ids) return;
|
|
285
|
-
for (const id of ids) delete window.
|
|
341
|
+
for (const id of ids) delete window.__vibeiterprops[id];
|
|
286
342
|
el._vibeIterPropIds = null;
|
|
287
343
|
};
|
|
288
344
|
free(node);
|
|
@@ -293,7 +349,7 @@ export const releaseOrphanedIterationProps = (nodes) => {
|
|
|
293
349
|
// For <component src> elements inside an iteration instance, evaluate any
|
|
294
350
|
// `@[expr]` attribute bindings against the iteration's scoped state and route
|
|
295
351
|
// every resolved value through the global iteration-prop registry. The prop
|
|
296
|
-
// attribute becomes `@[window.
|
|
352
|
+
// attribute becomes `@[window.__vibeiterprops._pN]` — a live binding into the
|
|
297
353
|
// registry slot — for both primitives and objects. The original expression is
|
|
298
354
|
// stashed on the element so the iteration's update path can re-evaluate it
|
|
299
355
|
// against the new scope and refresh the slot, propagating the change into the
|
|
@@ -327,7 +383,7 @@ export const resolveIterationComponentProps = (nodes, scopedState) => {
|
|
|
327
383
|
const registry = ensureIterPropsRegistry();
|
|
328
384
|
const id = `_p${__vibeIterPropCounter++}`;
|
|
329
385
|
registry[id] = value;
|
|
330
|
-
el.setAttribute(attr.name, `@[window.
|
|
386
|
+
el.setAttribute(attr.name, `@[window.__vibeiterprops.${id}]`);
|
|
331
387
|
el.setAttribute('data-vibe-iter-prop', '');
|
|
332
388
|
(el._vibeIterPropIds = el._vibeIterPropIds || []).push(id);
|
|
333
389
|
(el._vibeIterPropExprs = el._vibeIterPropExprs || []).push({ id, attrName: attr.name, expr });
|
|
@@ -388,6 +444,15 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
|
|
|
388
444
|
for (const { id, expr } of el._vibeIterPropExprs) {
|
|
389
445
|
try {
|
|
390
446
|
const value = evalInScope(expr, scopedState, el);
|
|
447
|
+
// Don't clobber a slot with undefined — mirrors the mount-time guard in
|
|
448
|
+
// resolveIterationComponentProps. forEachIterWrapper reaches every
|
|
449
|
+
// [data-vibe-iter-prop] descendant, including components owned by a
|
|
450
|
+
// DEEPER iteration (e.g. a cell component inside a nested each). Their
|
|
451
|
+
// prop expressions reference the inner each's alias, which isn't in this
|
|
452
|
+
// (outer) row's scope, so they evaluate to undefined here. Skipping keeps
|
|
453
|
+
// the value the inner iteration's own update already set with the correct
|
|
454
|
+
// scope, instead of wiping it to undefined and leaving raw @[...] bindings.
|
|
455
|
+
if (value === undefined) continue;
|
|
391
456
|
if (registry[id] !== value) {
|
|
392
457
|
registry[id] = value;
|
|
393
458
|
changed.add(id);
|
|
@@ -403,7 +468,7 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
|
|
|
403
468
|
|
|
404
469
|
// Walk an inlined component's parsed tree and force `updateIteration` on any
|
|
405
470
|
// iteration node whose arrayPath resolves through the iteration-prop registry
|
|
406
|
-
// (`window.
|
|
471
|
+
// (`window.__vibeiterprops._pN`). The registry slot was just refreshed in
|
|
407
472
|
// place by `refreshIterationComponentProps`, so `affected()` can't notice
|
|
408
473
|
// the change — both old/new evaluations of the path read the same updated
|
|
409
474
|
// value. `updateIteration` is the only place equipped to diff against
|
|
@@ -412,7 +477,7 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
|
|
|
412
477
|
// in sync. Without this, an `<inner-component>` whose template iterates over
|
|
413
478
|
// an array prop stays frozen on its initial-render items when the prop's
|
|
414
479
|
// contents change.
|
|
415
|
-
const REGISTRY_SLOT_REGEX = /
|
|
480
|
+
const REGISTRY_SLOT_REGEX = /__vibeiterprops\.(_p\d+)/;
|
|
416
481
|
const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope, changedSlots) => {
|
|
417
482
|
if (!tree) return;
|
|
418
483
|
if (tree.type === 'iteration') {
|
|
@@ -474,11 +539,19 @@ const applyDomPropertyWrites = (instances, array, state, itemAlias, indexAlias,
|
|
|
474
539
|
const { prop, expr } = domPropertyWrites[indexes[k] | 0];
|
|
475
540
|
const value = evalInScope(expr, localState, el);
|
|
476
541
|
if (el[prop] !== value) el[prop] = value;
|
|
477
|
-
if (
|
|
478
|
-
if (
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
542
|
+
if (prop === 'value') {
|
|
543
|
+
if (value === undefined || value === null) {
|
|
544
|
+
if (el.hasAttribute(prop)) el.removeAttribute(prop);
|
|
545
|
+
} else {
|
|
546
|
+
const str = String(value);
|
|
547
|
+
if (el.getAttribute(prop) !== str) el.setAttribute(prop, str);
|
|
548
|
+
}
|
|
549
|
+
} else if (value) {
|
|
550
|
+
// checked/selected are boolean — presence/absence is the truthful
|
|
551
|
+
// attribute form, matching the clone path in hydrate.js.
|
|
552
|
+
if (el.getAttribute(prop) !== '') el.setAttribute(prop, '');
|
|
553
|
+
} else if (el.hasAttribute(prop)) {
|
|
554
|
+
el.removeAttribute(prop);
|
|
482
555
|
}
|
|
483
556
|
}
|
|
484
557
|
el.removeAttribute('data-vibe-batch');
|
|
@@ -974,7 +1047,7 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
974
1047
|
// mutation was a registry slot rewrite. The previously rendered
|
|
975
1048
|
// items are the only honest record of what was there before.
|
|
976
1049
|
// 2. stateOldArray === newArray — the iteration's arrayPath resolves
|
|
977
|
-
// directly to a registry slot (`window.
|
|
1050
|
+
// directly to a registry slot (`window.__vibeiterprops._pN`); the
|
|
978
1051
|
// slot was swapped in place, so both reads return the same NEW
|
|
979
1052
|
// array.
|
|
980
1053
|
// 3. length mismatch — oldState predates the current render.
|
|
@@ -1030,14 +1103,31 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
1030
1103
|
// actually depend on what changed. Before tearing down and recreating every
|
|
1031
1104
|
// row, re-run the batch: if it yields identical HTML, the rows don't depend
|
|
1032
1105
|
// on what changed, so keep the existing DOM nodes — preserving their event
|
|
1033
|
-
// listeners (e.g. tooltip mouseleave) and any in-progress
|
|
1034
|
-
//
|
|
1035
|
-
//
|
|
1106
|
+
// listeners (e.g. tooltip mouseleave) and any in-progress click on a row
|
|
1107
|
+
// control. DOM-property writes (value/checked/etc.) aren't reflected in the
|
|
1108
|
+
// HTML string, so they're re-applied against the kept rows — property
|
|
1109
|
+
// assignment also wins over a user-dirtied checkbox, which an attribute
|
|
1110
|
+
// rewrite wouldn't.
|
|
1036
1111
|
const rt = iterationNode.runtime;
|
|
1037
|
-
if (rt.batchFn && rt.lastBatchHtml !== undefined
|
|
1112
|
+
if (rt.batchFn && rt.lastBatchHtml !== undefined) {
|
|
1038
1113
|
const stateValues = rt.stateKeys.map((k) => newState[k]);
|
|
1039
1114
|
const newHtml = rt.batchFn(newArray, ...stateValues, newState);
|
|
1040
|
-
if (newHtml === rt.lastBatchHtml)
|
|
1115
|
+
if (newHtml === rt.lastBatchHtml) {
|
|
1116
|
+
if (rt.domPropertyWrites?.length) {
|
|
1117
|
+
// Identical HTML implies identical row count — refresh item refs so
|
|
1118
|
+
// $scope handlers and property writes read the live array.
|
|
1119
|
+
for (let i = 0; i < instances.length; i++) instances[i].item = newArray[i];
|
|
1120
|
+
applyDomPropertyWrites(
|
|
1121
|
+
instances,
|
|
1122
|
+
newArray,
|
|
1123
|
+
newState,
|
|
1124
|
+
iterationNode.meta.itemAlias,
|
|
1125
|
+
iterationNode.meta.indexAlias,
|
|
1126
|
+
rt.domPropertyWrites,
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1041
1131
|
}
|
|
1042
1132
|
bulkReplace(iterationNode, newArray, newState, manifest, parentScope);
|
|
1043
1133
|
return;
|