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