@ape-egg/vibe 2.3.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -4
- package/boot.js +4 -4
- package/component.js +27 -29
- package/hot-module-refresh.js +4 -4
- package/index.js +10 -15
- package/llms.txt +8 -6
- package/package.json +19 -14
- package/runtime/affected.js +159 -36
- package/runtime/cleanup.js +45 -1
- package/runtime/component.js +312 -99
- package/runtime/conditionals.js +111 -14
- package/runtime/debug.js +24 -0
- package/runtime/dispatch.js +172 -0
- package/runtime/hydrate.js +251 -111
- package/runtime/index.js +180 -71
- package/runtime/iterate.js +125 -50
- package/runtime/iteration-utils.js +59 -8
- package/runtime/manifest.js +77 -2
- package/runtime/parse.js +69 -5
- package/runtime/pre-compiled-iterations.js +19 -6
- package/runtime/pre-compiled-manifest.js +13 -4
- package/runtime/staging.js +153 -0
- package/runtime/state.js +31 -0
- package/runtime/tracking.js +173 -0
- package/runtime/utils.js +155 -78
- package/spa.js +77 -14
- package/vibe.css +8 -4
- package/CHANGELOG.md +0 -1196
- package/ROADMAP.md +0 -397
- package/compiler/bin/vibe-compile.js +0 -121
- package/compiler/native/.gitkeep +0 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +0 -2023
- package/compiler/src/Cargo.toml +0 -38
- package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
- package/compiler/src/compiler/binding_case.rs +0 -88
- package/compiler/src/compiler/compile.rs +0 -2880
- package/compiler/src/compiler/component_tagger.rs +0 -469
- package/compiler/src/compiler/iteration_optimizer.rs +0 -455
- package/compiler/src/compiler/js_analyzer.rs +0 -715
- package/compiler/src/compiler/manifest_builder.rs +0 -693
- package/compiler/src/compiler/mod.rs +0 -16
- package/compiler/src/compiler/name_binding_protect.rs +0 -207
- package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
- package/compiler/src/compiler/spa.rs +0 -477
- package/compiler/src/compiler/state_extractor.rs +0 -263
- package/compiler/src/compiler/value_stamper.rs +0 -921
- package/compiler/src/compiler/watcher.rs +0 -1278
- package/compiler/src/config.rs +0 -279
- package/compiler/src/main.rs +0 -358
- package/compiler/src/parser/element.rs +0 -96
- package/compiler/src/parser/html.rs +0 -1004
- package/compiler/src/parser/mod.rs +0 -8
- package/runtime/pre-compiled-manifest.test.mjs +0 -58
- package/runtime/scope.js +0 -50
- package/test-results/.last-run.json +0 -4
package/runtime/component.js
CHANGED
|
@@ -7,12 +7,33 @@ import {
|
|
|
7
7
|
BINDING_REGEX,
|
|
8
8
|
THIS_PROP_REGEX,
|
|
9
9
|
STATE_THIS_PROP_REGEX,
|
|
10
|
+
CONDITIONAL_START_REGEX,
|
|
11
|
+
ITERATION_START_REGEX,
|
|
10
12
|
} from './constants.js';
|
|
11
13
|
import { evalInScope } from './utils.js';
|
|
14
|
+
import { bumpIterPropGeneration } from './iteration-utils.js';
|
|
12
15
|
import { fetchComponentTemplate, isComponentCached } from './component-cache.js';
|
|
16
|
+
import { notifyChanged } from './state.js';
|
|
17
|
+
import {
|
|
18
|
+
liveNode,
|
|
19
|
+
activeOutgoingRoots,
|
|
20
|
+
markOutgoing,
|
|
21
|
+
releaseOutgoing,
|
|
22
|
+
stageIncoming,
|
|
23
|
+
commitStaged,
|
|
24
|
+
abandonStaged,
|
|
25
|
+
isComponentWrapper,
|
|
26
|
+
} from './staging.js';
|
|
27
|
+
|
|
28
|
+
export { activeOutgoingRoots, isComponentWrapper };
|
|
13
29
|
|
|
14
30
|
// Deterministic component counter
|
|
15
|
-
|
|
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
|
+
let componentCounter = 1000000;
|
|
16
37
|
|
|
17
38
|
/**
|
|
18
39
|
* Generate unique component ID
|
|
@@ -41,7 +62,7 @@ export const releaseOrphanedComponentState = (collectedIds) => {
|
|
|
41
62
|
if (!collectedIds || collectedIds.size === 0) return;
|
|
42
63
|
for (const id of collectedIds) {
|
|
43
64
|
if (document.querySelector(`[data-vibe-component-id="${id}"]`)) continue;
|
|
44
|
-
delete window.
|
|
65
|
+
delete window.__vibe?.components?.[id];
|
|
45
66
|
// CLEANUP OF CURRENT STATE
|
|
46
67
|
delete window.$[id];
|
|
47
68
|
runComponentCleanups(id);
|
|
@@ -54,10 +75,20 @@ export const releaseOrphanedComponentState = (collectedIds) => {
|
|
|
54
75
|
// unmounting the component fires the callbacks so the previous evaluation's
|
|
55
76
|
// listeners don't accumulate alongside fresh registrations.
|
|
56
77
|
export const runComponentCleanups = (componentId) => {
|
|
57
|
-
const cleanups = window.
|
|
78
|
+
const cleanups = window.__vibe?.cleanups?.[componentId];
|
|
58
79
|
if (!cleanups) return;
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
delete window.__vibe.cleanups[componentId];
|
|
85
|
+
for (let i = 0; i < cleanups.length; i++) {
|
|
86
|
+
try {
|
|
87
|
+
cleanups[i]();
|
|
88
|
+
} catch (e) {
|
|
89
|
+
console.error('[vibe] Error in unmount cleanup:', e);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
61
92
|
};
|
|
62
93
|
|
|
63
94
|
// Per-script `$` Proxy. Bare `$` references in a component's <script> resolve
|
|
@@ -81,8 +112,8 @@ const createScopedDollar = (componentId) => {
|
|
|
81
112
|
// leaking them past its lifetime. At page level the root's `on`
|
|
82
113
|
// resolves the same event name to pagehide instead.
|
|
83
114
|
if (event === 'unmount') {
|
|
84
|
-
|
|
85
|
-
const slot =
|
|
115
|
+
const cleanupsReg = ((window.__vibe ??= {}).cleanups ??= {});
|
|
116
|
+
const slot = cleanupsReg[componentId] || (cleanupsReg[componentId] = []);
|
|
86
117
|
slot.push(callback);
|
|
87
118
|
return () => {
|
|
88
119
|
const i = slot.indexOf(callback);
|
|
@@ -90,8 +121,8 @@ const createScopedDollar = (componentId) => {
|
|
|
90
121
|
};
|
|
91
122
|
}
|
|
92
123
|
const unsub = target.on(event, callback);
|
|
93
|
-
|
|
94
|
-
const slot =
|
|
124
|
+
const cleanupsReg = ((window.__vibe ??= {}).cleanups ??= {});
|
|
125
|
+
const slot = cleanupsReg[componentId] || (cleanupsReg[componentId] = []);
|
|
95
126
|
slot.push(unsub);
|
|
96
127
|
return unsub;
|
|
97
128
|
};
|
|
@@ -173,8 +204,16 @@ const transformScriptContent = (rawContent) => {
|
|
|
173
204
|
//
|
|
174
205
|
// Returns a Promise when any script is async (has imports) — the caller
|
|
175
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.
|
|
176
214
|
export const executeCompiledComponentScripts = () => {
|
|
177
|
-
const scripts =
|
|
215
|
+
const scripts = [];
|
|
216
|
+
collectMountedModuleScripts(document.body ? [document.body] : [], scripts);
|
|
178
217
|
if (!scripts.length) return null;
|
|
179
218
|
advanceComponentCounterPastIds(document);
|
|
180
219
|
return runVibeModuleScripts(scripts);
|
|
@@ -189,16 +228,43 @@ export const executeCompiledComponentScripts = () => {
|
|
|
189
228
|
// conditional restores its markup but its `<!-- each _cN.x -->` reads state that
|
|
190
229
|
// was released on unmount (the AccountProgression overlay rendering blank on
|
|
191
230
|
// second open).
|
|
192
|
-
|
|
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
|
+
export const executeCompiledComponentScriptsIn = (nodes, { silent = false } = {}) => {
|
|
193
238
|
const scripts = [];
|
|
194
|
-
|
|
195
|
-
if (node.nodeType !== 1) continue;
|
|
196
|
-
if (node.matches?.('script[type="vibe-module"]')) scripts.push(node);
|
|
197
|
-
node.querySelectorAll?.('script[type="vibe-module"]').forEach((s) => scripts.push(s));
|
|
198
|
-
}
|
|
239
|
+
collectMountedModuleScripts(nodes, scripts);
|
|
199
240
|
if (!scripts.length) return null;
|
|
200
241
|
advanceComponentCounterPastIds(document);
|
|
201
|
-
return runVibeModuleScripts(scripts);
|
|
242
|
+
return runVibeModuleScripts(scripts, silent);
|
|
243
|
+
};
|
|
244
|
+
|
|
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
|
+
const collectMountedModuleScripts = (nodes, out) => {
|
|
256
|
+
let depth = 0;
|
|
257
|
+
for (const node of nodes) {
|
|
258
|
+
if (node.nodeType === 8) {
|
|
259
|
+
const text = node.textContent.trim();
|
|
260
|
+
if (CONDITIONAL_START_REGEX.test(text) || ITERATION_START_REGEX.test(text)) depth++;
|
|
261
|
+
else if (text === '/if' || text === '/each') depth--;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (depth > 0 || node.nodeType !== 1) continue;
|
|
265
|
+
if (node.matches?.('script[type="vibe-module"]')) out.push(node);
|
|
266
|
+
else collectMountedModuleScripts(node.childNodes, out);
|
|
267
|
+
}
|
|
202
268
|
};
|
|
203
269
|
|
|
204
270
|
// Build-time tagging already assigned _cN ids to wrappers; advance the runtime
|
|
@@ -210,48 +276,131 @@ const advanceComponentCounterPastIds = (root) => {
|
|
|
210
276
|
});
|
|
211
277
|
};
|
|
212
278
|
|
|
213
|
-
|
|
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
|
+
const registerComponentState = (componentId, state, silent) => {
|
|
285
|
+
((window.__vibe ??= {}).components ??= {})[componentId] = state;
|
|
286
|
+
if (window.$) {
|
|
287
|
+
if (silent && window.$._register) window.$._register(componentId, state);
|
|
288
|
+
else window.$[componentId] = state;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
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
|
+
let scriptChain = null;
|
|
305
|
+
|
|
306
|
+
const enqueueScriptExecution = (run) => {
|
|
307
|
+
if (scriptChain) {
|
|
308
|
+
const link = scriptChain.then(run);
|
|
309
|
+
const tail = link.then(() => {
|
|
310
|
+
if (scriptChain === tail) scriptChain = null;
|
|
311
|
+
});
|
|
312
|
+
scriptChain = tail;
|
|
313
|
+
return link;
|
|
314
|
+
}
|
|
315
|
+
const result = run();
|
|
316
|
+
if (!result) return null;
|
|
317
|
+
const tail = result.then(() => {
|
|
318
|
+
if (scriptChain === tail) scriptChain = null;
|
|
319
|
+
});
|
|
320
|
+
scriptChain = tail;
|
|
321
|
+
return result;
|
|
322
|
+
};
|
|
323
|
+
|
|
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
|
+
const executeScriptUnit = (componentId, content, hasImports, scopedDollar, componentFn) => () => {
|
|
329
|
+
runComponentCleanups(componentId);
|
|
330
|
+
try {
|
|
331
|
+
if (hasImports) {
|
|
332
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
333
|
+
return new AsyncFunction('$', 'component', content)(scopedDollar, componentFn).catch((e) => {
|
|
334
|
+
console.warn('[vibe] Failed to execute component script:', e);
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
new Function('$', 'component', content)(scopedDollar, componentFn);
|
|
338
|
+
} catch (e) {
|
|
339
|
+
console.warn('[vibe] Failed to execute component script:', e);
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
|
|
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
|
+
const wrapperSetupScript = (wrapper) => {
|
|
351
|
+
for (const s of wrapper.querySelectorAll('script[type="vibe-module"]')) {
|
|
352
|
+
if (s.closest('[data-vibe-component-id]') === wrapper) return s;
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const runVibeModuleScripts = (scripts, silent = false) => {
|
|
214
358
|
const asyncTasks = [];
|
|
215
|
-
const
|
|
359
|
+
const registeredIds = [];
|
|
216
360
|
|
|
217
361
|
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
|
+
if (script.__vibeExecuted) continue;
|
|
368
|
+
|
|
218
369
|
// Parity with the fetch path: dehydrated components never execute
|
|
219
370
|
if (script.closest(`[${DEHYDRATE_CLASS_OR_ATTR}], .${DEHYDRATE_CLASS_OR_ATTR}`)) continue;
|
|
220
371
|
|
|
221
372
|
const rawContent = script.textContent?.trim() || '';
|
|
222
373
|
if (!rawContent) continue;
|
|
374
|
+
script.__vibeExecuted = true;
|
|
223
375
|
|
|
224
|
-
// The build tagged each component wrapper with its deterministic id —
|
|
225
|
-
// the same id the stamped bindings reference. First script in a wrapper
|
|
226
|
-
// claims it; additional scripts get fresh ids (mirrors the per-script
|
|
227
|
-
// ids of the fetch path).
|
|
228
376
|
const wrapper = script.closest('[data-vibe-component-id]');
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
377
|
+
const componentId =
|
|
378
|
+
wrapper && wrapperSetupScript(wrapper) === script
|
|
379
|
+
? wrapper.getAttribute('data-vibe-component-id')
|
|
380
|
+
: generateComponentId();
|
|
232
381
|
|
|
233
382
|
const { content, hasImports } = transformScriptContent(rawContent);
|
|
234
383
|
|
|
235
384
|
const componentFn = (state) => {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (window.$) window.$[componentId] = state;
|
|
385
|
+
registerComponentState(componentId, state, silent);
|
|
386
|
+
registeredIds.push(componentId);
|
|
239
387
|
return componentId;
|
|
240
388
|
};
|
|
241
389
|
|
|
242
|
-
|
|
243
|
-
|
|
390
|
+
const task = enqueueScriptExecution(
|
|
391
|
+
executeScriptUnit(componentId, content, hasImports, createScopedDollar(componentId), componentFn),
|
|
392
|
+
);
|
|
393
|
+
if (task) asyncTasks.push(task);
|
|
394
|
+
}
|
|
244
395
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
new Function('$', 'component', content)(scopedDollar, componentFn);
|
|
251
|
-
}
|
|
252
|
-
} catch (e) {
|
|
253
|
-
console.warn('[vibe] Failed to execute component script:', e);
|
|
396
|
+
// Silent mode: one grouped commit once every script has registered, so
|
|
397
|
+
// whole-`$` observers see all fresh keys with a single flush.
|
|
398
|
+
if (silent) {
|
|
399
|
+
if (asyncTasks.length) {
|
|
400
|
+
return Promise.all(asyncTasks).then(() => notifyChanged(registeredIds));
|
|
254
401
|
}
|
|
402
|
+
notifyChanged(registeredIds);
|
|
403
|
+
return null;
|
|
255
404
|
}
|
|
256
405
|
|
|
257
406
|
return asyncTasks.length ? Promise.all(asyncTasks) : null;
|
|
@@ -279,21 +428,10 @@ export const abortComponentFetch = (element) => {
|
|
|
279
428
|
}
|
|
280
429
|
};
|
|
281
430
|
|
|
282
|
-
// A fetched-component host: `<component>` or `<div class="component">`.
|
|
283
|
-
export const isComponentWrapper = (el) =>
|
|
284
|
-
el.nodeName === 'COMPONENT' ||
|
|
285
|
-
(el.nodeName === 'DIV' && el.classList?.contains('component'));
|
|
286
|
-
|
|
287
431
|
// The live element a reactive src binding acts on. The manifest tree keeps the
|
|
288
|
-
// ORIGINAL element, but every (re)mount replaces the wrapper
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
export const liveComponentWrapper = (element) => {
|
|
292
|
-
let live = element;
|
|
293
|
-
while (live._vibeReplacedBy) live = live._vibeReplacedBy;
|
|
294
|
-
if (live !== element) element._vibeReplacedBy = live;
|
|
295
|
-
return live;
|
|
296
|
-
};
|
|
432
|
+
// ORIGINAL element, but every (re)mount replaces the wrapper — staging.js's
|
|
433
|
+
// chain resolver is the one implementation.
|
|
434
|
+
export const liveComponentWrapper = liveNode;
|
|
297
435
|
|
|
298
436
|
// (Re)mount a component for a reactive src binding (`src="@[page.src]"`).
|
|
299
437
|
// Three phases of a wrapper's life, one entry point:
|
|
@@ -322,6 +460,13 @@ export const remountComponent = (el, src, debug = false) => {
|
|
|
322
460
|
// attribute) was invisible to that pass, so hydration owns its first
|
|
323
461
|
// fetch too — the observer doesn't watch attributes.
|
|
324
462
|
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
|
+
markOutgoing(el);
|
|
325
470
|
processSingle(el, debug);
|
|
326
471
|
};
|
|
327
472
|
|
|
@@ -333,6 +478,9 @@ export const remountComponent = (el, src, debug = false) => {
|
|
|
333
478
|
export const forceRemount = (el, debug = false) => {
|
|
334
479
|
if (pendingFetches.has(el) || el._vibeMountedSrc === undefined) return;
|
|
335
480
|
el.setAttribute('src', el._vibeMountedSrc);
|
|
481
|
+
// Same outgoing freeze as remountComponent — a keyed remount replaces the
|
|
482
|
+
// mounted content just the same.
|
|
483
|
+
markOutgoing(el);
|
|
336
484
|
processSingle(el, debug);
|
|
337
485
|
};
|
|
338
486
|
|
|
@@ -396,8 +544,15 @@ const tagScriptSiblings = (script, componentId) => {
|
|
|
396
544
|
if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
|
|
397
545
|
break;
|
|
398
546
|
}
|
|
399
|
-
sibling
|
|
400
|
-
|
|
547
|
+
// A sibling already carrying an id is a build-tagged compiled wrapper
|
|
548
|
+
// that owns its scope — its `this.` bindings were rewritten to that id
|
|
549
|
+
// at compile time. Overwriting it re-scopes the wrapper to THIS script's
|
|
550
|
+
// id and orphans every `@[_cN.x]` binding inside it (the game's
|
|
551
|
+
// top-level create-teamplay modal losing its state).
|
|
552
|
+
if (!sibling.hasAttribute('data-vibe-component-id')) {
|
|
553
|
+
sibling.setAttribute('data-vibe-component-id', componentId);
|
|
554
|
+
rewriteThisBindings(sibling, componentId);
|
|
555
|
+
}
|
|
401
556
|
sibling = sibling.nextElementSibling;
|
|
402
557
|
}
|
|
403
558
|
};
|
|
@@ -579,6 +734,13 @@ const processSingle = (el, debug) => {
|
|
|
579
734
|
|
|
580
735
|
return fetchComponentTemplate(src, controller.signal)
|
|
581
736
|
.then((html) => {
|
|
737
|
+
// Superseded before the template arrived: the cache path resolves
|
|
738
|
+
// regardless of the abort signal (aborting a cache hit is meaningless
|
|
739
|
+
// network-wise), so the supersede check lives here. A newer remount
|
|
740
|
+
// owns the wrapper — running this mount's scripts or finalize would
|
|
741
|
+
// land the stale fragment and discard the new one.
|
|
742
|
+
if (controller.signal.aborted) return;
|
|
743
|
+
|
|
582
744
|
// Parse HTML in temporary container to process component scripts
|
|
583
745
|
const temp = createDetached('div');
|
|
584
746
|
temp.innerHTML = html;
|
|
@@ -620,9 +782,10 @@ const processSingle = (el, debug) => {
|
|
|
620
782
|
// Sibling tagging is handled below (before script.remove()) so it works
|
|
621
783
|
// for both sync and async scripts.
|
|
622
784
|
const componentFn = (state) => {
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
785
|
+
// Fetched-mount registration is silent: the fetched subtree's
|
|
786
|
+
// hydration runs after replaceWith against live `$`, so a fresh id
|
|
787
|
+
// needs no flush (see registerComponentState / $.register).
|
|
788
|
+
registerComponentState(componentId, state, true);
|
|
626
789
|
if (!registeredComponentIds.includes(componentId)) {
|
|
627
790
|
registeredComponentIds.push(componentId);
|
|
628
791
|
}
|
|
@@ -633,34 +796,17 @@ const processSingle = (el, debug) => {
|
|
|
633
796
|
return componentId;
|
|
634
797
|
};
|
|
635
798
|
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
// closure — no opt-in required.
|
|
648
|
-
const scopedDollar = createScopedDollar(componentId);
|
|
649
|
-
|
|
650
|
-
// Execute script with component() function in scope
|
|
651
|
-
try {
|
|
652
|
-
if (hasImports) {
|
|
653
|
-
// Async execution for scripts with imports
|
|
654
|
-
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
655
|
-
asyncTasks.push(new AsyncFunction('$', 'component', scriptContent)(scopedDollar, componentFn));
|
|
656
|
-
} else {
|
|
657
|
-
// Synchronous execution for scripts without imports (preserves boot timing)
|
|
658
|
-
const executeFn = new Function('$', 'component', scriptContent);
|
|
659
|
-
executeFn(scopedDollar, componentFn);
|
|
660
|
-
}
|
|
661
|
-
} catch (e) {
|
|
662
|
-
console.warn('[vibe] Failed to execute component script:', e);
|
|
663
|
-
}
|
|
799
|
+
// Execute through the global script chain (see enqueueScriptExecution):
|
|
800
|
+
// cleanups for a reused componentId (HMR remount) tear down the prior
|
|
801
|
+
// evaluation's listeners right before the fresh run, the scoped `$`
|
|
802
|
+
// tracks this script's `.on(...)` registrations, and document-order
|
|
803
|
+
// execution holds even against scripts from other batches (a fetched
|
|
804
|
+
// component's script never runs while the shell's boot import is
|
|
805
|
+
// still in flight).
|
|
806
|
+
const task = enqueueScriptExecution(
|
|
807
|
+
executeScriptUnit(componentId, scriptContent, hasImports, createScopedDollar(componentId), componentFn),
|
|
808
|
+
);
|
|
809
|
+
if (task) asyncTasks.push(task);
|
|
664
810
|
|
|
665
811
|
// Tag siblings + rewrite this. bindings using shared helper. Runs
|
|
666
812
|
// BEFORE script.remove() so nextElementSibling is valid.
|
|
@@ -670,8 +816,27 @@ const processSingle = (el, debug) => {
|
|
|
670
816
|
script.remove();
|
|
671
817
|
}
|
|
672
818
|
|
|
819
|
+
// Evict everything this mount's scripts registered — state buckets AND
|
|
820
|
+
// their $.on listeners/unmount side effects. The scripts already RAN
|
|
821
|
+
// (execution precedes finalize), so skipping the cleanups here leaks
|
|
822
|
+
// live global listeners for DOM that never mounts.
|
|
823
|
+
const releaseRegisteredIds = () => {
|
|
824
|
+
for (const id of registeredComponentIds) {
|
|
825
|
+
delete window.__vibe?.components?.[id];
|
|
826
|
+
if (window.$) delete window.$[id];
|
|
827
|
+
runComponentCleanups(id);
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
|
|
673
831
|
// Finalize: props, slots, DOM replacement
|
|
674
832
|
const finalize = () => {
|
|
833
|
+
// Superseded DURING script execution (an async import held this mount
|
|
834
|
+
// while a newer remount aborted it): the newer fetch owns the wrapper.
|
|
835
|
+
if (controller.signal.aborted) {
|
|
836
|
+
releaseRegisteredIds();
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
|
|
675
840
|
// Delegate prop substitution + slot inlining to shared helper.
|
|
676
841
|
const transformedHtml = renderPropsAndSlot(temp, props, children);
|
|
677
842
|
|
|
@@ -726,7 +891,7 @@ const processSingle = (el, debug) => {
|
|
|
726
891
|
// detached `<component src>` to the new wrapper. The detached element
|
|
727
892
|
// would otherwise trigger releaseOrphanedIterationProps and free the
|
|
728
893
|
// registry slots that the inlined template's bindings still reference,
|
|
729
|
-
// causing every `@[window.
|
|
894
|
+
// causing every `@[window.__vibe.iterProps._pN]` to resolve to undefined
|
|
730
895
|
// on the next hydrate.
|
|
731
896
|
if (el._vibeIterPropIds) {
|
|
732
897
|
newWrapper._vibeIterPropIds = el._vibeIterPropIds;
|
|
@@ -740,6 +905,7 @@ const processSingle = (el, debug) => {
|
|
|
740
905
|
if (el.hasAttribute('data-vibe-iter-prop')) {
|
|
741
906
|
newWrapper.setAttribute('data-vibe-iter-prop', '');
|
|
742
907
|
el.removeAttribute('data-vibe-iter-prop');
|
|
908
|
+
bumpIterPropGeneration();
|
|
743
909
|
}
|
|
744
910
|
// Transfer the original prop expressions too, so the iteration's
|
|
745
911
|
// update path can re-evaluate them against the row's new scope and
|
|
@@ -765,37 +931,77 @@ const processSingle = (el, debug) => {
|
|
|
765
931
|
// that parsed and hydrated this subtree.
|
|
766
932
|
newWrapper.setAttribute('vibe-fouc', '');
|
|
767
933
|
el._vibeReplacedBy = newWrapper;
|
|
768
|
-
el.
|
|
934
|
+
if (el._vibeOutgoing) {
|
|
935
|
+
// REMOUNT (route/key change): stage, don't swap — staging.js owns
|
|
936
|
+
// the transition (display:none sibling now, one-paint commit in
|
|
937
|
+
// tryReveal below).
|
|
938
|
+
stageIncoming(el, newWrapper);
|
|
939
|
+
} else {
|
|
940
|
+
el.replaceWith(newWrapper);
|
|
941
|
+
}
|
|
769
942
|
debugLog(fromCache ? PHASE_FETCH_CACHED : PHASE_FETCH, src, debug);
|
|
770
943
|
|
|
944
|
+
// The mount's own script registrations commit as one batch — the
|
|
945
|
+
// subtree hydrates via the observer against live `$`, so the only
|
|
946
|
+
// consumers needing a flush are whole-`$` observers elsewhere.
|
|
947
|
+
notifyChanged(registeredComponentIds);
|
|
948
|
+
|
|
771
949
|
// Build-inlined child components (compiled SPA fragments) arrive
|
|
772
950
|
// with tagged wrapper ids and vibe-module scripts — the compiled-
|
|
773
951
|
// document form. A fetched mount is the fourth delivery mode after
|
|
774
952
|
// boot, conditional branches, and iteration rows: run those
|
|
775
953
|
// scripts now so each child's component({...}) state registers
|
|
776
|
-
// under its build-tagged id and the _cN bindings hydrate.
|
|
777
|
-
|
|
954
|
+
// under its build-tagged id and the _cN bindings hydrate. Silent:
|
|
955
|
+
// their registrations commit as one grouped notify when the
|
|
956
|
+
// scripts settle — per-script flushes would re-walk the page N
|
|
957
|
+
// times (the 26-script game fragments spent ~150ms/navigation on
|
|
958
|
+
// exactly that). The settle promise also gates the reveal below:
|
|
959
|
+
// a slow module import delays registration, and until it lands the
|
|
960
|
+
// subtree's `this.`-derived bindings hydrate to junk (NaN widths —
|
|
961
|
+
// the game's "Making potion" bar painting full/empty mid-mount).
|
|
962
|
+
const scriptsSettled = Promise.resolve(
|
|
963
|
+
executeCompiledComponentScriptsIn([newWrapper], { silent: true }),
|
|
964
|
+
);
|
|
778
965
|
|
|
779
966
|
// MutationObserver handles parsing and hydrating the new content.
|
|
780
967
|
// Branch nodes are registered in the manifest by mountBranch,
|
|
781
968
|
// so the observer can find parents even inside conditional branches.
|
|
782
969
|
// Hydration can span multiple batches (nested fetched components,
|
|
783
970
|
// async scripts) with paints in between — reveal only when the
|
|
784
|
-
//
|
|
971
|
+
// wrapper's scripts have settled AND the subtree carries no raw
|
|
972
|
+
// bindings (same predicate the page-level ready uses). The grouped
|
|
973
|
+
// registration notify queues its correction flush BEFORE the settle
|
|
974
|
+
// callback runs, so the recheck sees post-correction DOM in the
|
|
975
|
+
// same microtask drain — the first painted frame is the true one.
|
|
785
976
|
// A wrapper unmounted mid-hydration releases the hook.
|
|
786
|
-
|
|
787
|
-
|
|
977
|
+
let scriptsDone = false;
|
|
978
|
+
const tryReveal = () => {
|
|
979
|
+
if (!newWrapper.isConnected) {
|
|
980
|
+
abandonStaged(newWrapper);
|
|
981
|
+
newWrapper.removeAttribute('vibe-fouc');
|
|
982
|
+
unfouc();
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
if (!scriptsDone || !shouldCleanup(newWrapper)) return;
|
|
986
|
+
// Atomic visual commit: old page out, parked styling-context
|
|
987
|
+
// bindings applied, new page revealed — one synchronous block,
|
|
988
|
+
// one paint. Until this moment the outgoing page was still the
|
|
989
|
+
// one on screen, fully styled.
|
|
990
|
+
commitStaged(newWrapper);
|
|
788
991
|
newWrapper.removeAttribute('vibe-fouc');
|
|
789
992
|
unfouc();
|
|
993
|
+
};
|
|
994
|
+
const unfouc = window.$.on('afterDomMutation', tryReveal);
|
|
995
|
+
scriptsSettled.then(() => {
|
|
996
|
+
scriptsDone = true;
|
|
997
|
+
tryReveal();
|
|
790
998
|
});
|
|
791
999
|
} else {
|
|
792
1000
|
// Element was detached before finalize ran (conditional unmounted
|
|
793
1001
|
// during fetch, parent removed, etc). Release any state component()
|
|
794
1002
|
// calls registered — otherwise it leaks on `$` forever.
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
if (window.$) delete window.$[id];
|
|
798
|
-
}
|
|
1003
|
+
releaseOutgoing(el);
|
|
1004
|
+
releaseRegisteredIds();
|
|
799
1005
|
}
|
|
800
1006
|
};
|
|
801
1007
|
|
|
@@ -810,12 +1016,19 @@ const processSingle = (el, debug) => {
|
|
|
810
1016
|
// Clean up pending fetch tracker — ownership-guarded (see finalize)
|
|
811
1017
|
if (pendingFetches.get(el) === controller) pendingFetches.delete(el);
|
|
812
1018
|
|
|
813
|
-
//
|
|
1019
|
+
// Aborted fetch: a removal-abort (wrapper left the DOM mid-remount)
|
|
1020
|
+
// has no commit left to release its outgoing freeze — free the root
|
|
1021
|
+
// here or the detached page subtree stays pinned in
|
|
1022
|
+
// activeOutgoingRoots for the session. A supersede-abort (newer
|
|
1023
|
+
// remount on the same, still-connected wrapper) keeps it: the new
|
|
1024
|
+
// fetch owns the freeze.
|
|
814
1025
|
if (error.name === 'AbortError') {
|
|
1026
|
+
if (!el.isConnected) releaseOutgoing(el);
|
|
815
1027
|
return;
|
|
816
1028
|
}
|
|
817
1029
|
|
|
818
1030
|
console.error('[vibe] Failed to load:', src, error);
|
|
1031
|
+
releaseOutgoing(el);
|
|
819
1032
|
if (el.parentNode) {
|
|
820
1033
|
el.remove();
|
|
821
1034
|
}
|