@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/affected.js
CHANGED
|
@@ -12,10 +12,6 @@ import {
|
|
|
12
12
|
} from "./utils.js";
|
|
13
13
|
import { beginTracking, endTracking, unsubscribe } from "./tracking.js";
|
|
14
14
|
|
|
15
|
-
// Drop the subscribers of a stale binding-cache list. A cache rebuild means
|
|
16
|
-
// the parsed source or element changed — the old binding objects are dead,
|
|
17
|
-
// but their anchors may still be connected (a reparse of a live element), so
|
|
18
|
-
// disconnect-pruning alone would leave them serving stale patches.
|
|
19
15
|
const unsubscribeBindings = (list) => {
|
|
20
16
|
if (!list) return;
|
|
21
17
|
for (let i = 0; i < list.length; i++) {
|
|
@@ -23,12 +19,6 @@ const unsubscribeBindings = (list) => {
|
|
|
23
19
|
}
|
|
24
20
|
};
|
|
25
21
|
|
|
26
|
-
// The @[...] bindings in a tree node are static: the parsed template string and
|
|
27
|
-
// the element's component scope don't change between renders. Re-running the
|
|
28
|
-
// regex + resolveThisPath on every affected-walk was a large per-frame cost in
|
|
29
|
-
// hot iterations (combat at 60fps re-extracts every binding of every row). Cache
|
|
30
|
-
// the extracted + scope-resolved bindings on the node, keyed by the parsed source
|
|
31
|
-
// and element so a re-parse or element swap recomputes.
|
|
32
22
|
const textBindingsOf = (tree) => {
|
|
33
23
|
if (tree._tbSrc === tree.parsed && tree._tbEl === tree.element) return tree._tb;
|
|
34
24
|
unsubscribeBindings(tree._tb);
|
|
@@ -45,8 +35,6 @@ const textBindingsOf = (tree) => {
|
|
|
45
35
|
tokens: tokensOf(resolvedInner),
|
|
46
36
|
});
|
|
47
37
|
}
|
|
48
|
-
// Back-ref so dispatch can expand one dirty binding to its whole text
|
|
49
|
-
// span (a text node hydrates all-or-nothing).
|
|
50
38
|
for (let i = 0; i < out.length; i++) out[i]._group = out;
|
|
51
39
|
tree._tb = out;
|
|
52
40
|
tree._tbSrc = tree.parsed;
|
|
@@ -75,9 +63,6 @@ const attrBindingsOf = (tree) => {
|
|
|
75
63
|
return out;
|
|
76
64
|
};
|
|
77
65
|
|
|
78
|
-
// Name bindings (`<icon @[fx.icon]>`) live as raw strings on tree.nameBindings.
|
|
79
|
-
// Extract + this-resolve + tokenize them once, same cache discipline as the
|
|
80
|
-
// text/attr binding caches above.
|
|
81
66
|
const nameBindingsOf = (tree) => {
|
|
82
67
|
if (tree._nbSrc === tree.nameBindings && tree._nbEl === tree.element) return tree._nb;
|
|
83
68
|
unsubscribeBindings(tree._nb);
|
|
@@ -96,20 +81,12 @@ const nameBindingsOf = (tree) => {
|
|
|
96
81
|
return out;
|
|
97
82
|
};
|
|
98
83
|
|
|
99
|
-
// Evaluate conditional expression
|
|
100
84
|
const evaluateCondition = (expression, state, element = null) =>
|
|
101
85
|
!!evalInScope(expression, state, element);
|
|
102
86
|
|
|
103
|
-
// One subscriber per conditional/iteration tree node, hosted on the node —
|
|
104
|
-
// stable across flushes, dead with the node. The start comment anchors
|
|
105
|
-
// liveness (disconnect-pruning).
|
|
106
87
|
export const nodeSubscriberOf = (tree, kind) =>
|
|
107
88
|
(tree._sub ??= { kind, node: tree, anchor: tree.meta.startComment });
|
|
108
89
|
|
|
109
|
-
// Helper function to check if a match references a specific key.
|
|
110
|
-
// Fast path: exact match or property access (`user.name` matches `user`).
|
|
111
|
-
// Slow path: word-boundary search for complex expressions like `Math.floor(coins / 100)`
|
|
112
|
-
// where the key appears as an identifier anywhere in the expression.
|
|
113
90
|
const isIdentChar = (c) =>
|
|
114
91
|
(c >= "a" && c <= "z") ||
|
|
115
92
|
(c >= "A" && c <= "Z") ||
|
|
@@ -117,12 +94,6 @@ const isIdentChar = (c) =>
|
|
|
117
94
|
c === "_" ||
|
|
118
95
|
c === "$";
|
|
119
96
|
|
|
120
|
-
// Split an expression into its identifier tokens — every maximal run of
|
|
121
|
-
// identifier characters. This is exactly the set of standalone identifiers the
|
|
122
|
-
// old per-key `matchesKey` scan could match, so intersecting these tokens with
|
|
123
|
-
// the state-key set is equivalent to "which state keys does this binding read"
|
|
124
|
-
// for identifier-named keys, but costs O(expr length) once (cached) instead of
|
|
125
|
-
// O(stateKeys) on every frame. Deduped so the per-frame change check stays tight.
|
|
126
97
|
const tokensOf = (expr) => {
|
|
127
98
|
const seen = new Set();
|
|
128
99
|
let i = 0;
|
|
@@ -138,11 +109,6 @@ const tokensOf = (expr) => {
|
|
|
138
109
|
return [...seen];
|
|
139
110
|
};
|
|
140
111
|
|
|
141
|
-
// Memoize the own-key set of a state snapshot so binding checks do O(1)
|
|
142
|
-
// membership tests instead of scanning the key array. Keyed by the snapshot
|
|
143
|
-
// object itself — scoped-state proxies and merged plain objects are reused
|
|
144
|
-
// across every node of a single affected() descent, so the Set is built once
|
|
145
|
-
// per snapshot, not once per binding.
|
|
146
112
|
const keySetCache = new WeakMap();
|
|
147
113
|
const keySetOf = (snapshot) => {
|
|
148
114
|
let set = keySetCache.get(snapshot);
|
|
@@ -153,13 +119,6 @@ const keySetOf = (snapshot) => {
|
|
|
153
119
|
return set;
|
|
154
120
|
};
|
|
155
121
|
|
|
156
|
-
// A binding is affected on update when it reads a state key whose value changed,
|
|
157
|
-
// OR when it reads no known state key at all (can't prove it's unaffected, so
|
|
158
|
-
// re-evaluate — hydrate self-guards the DOM write). False can only be returned
|
|
159
|
-
// when every key the binding reads is present AND unchanged. A key absent from
|
|
160
|
-
// the OLD snapshot is a change even when its new value is undefined — mount
|
|
161
|
-
// walks diff against {}, and skipping there means the binding never reaches
|
|
162
|
-
// tracked() and never registers a subscriber.
|
|
163
122
|
const bindingAffected = (tokens, keySet, state, newState) => {
|
|
164
123
|
let matched = false;
|
|
165
124
|
for (let i = 0; i < tokens.length; i++) {
|
|
@@ -172,10 +131,6 @@ const bindingAffected = (tokens, keySet, state, newState) => {
|
|
|
172
131
|
return !matched;
|
|
173
132
|
};
|
|
174
133
|
|
|
175
|
-
// Name bindings (`<icon @[fx.convertIcon]>`) arrive lowercased from HTML while
|
|
176
|
-
// state keys stay camelCase, so they match keys case-insensitively. This map
|
|
177
|
-
// resolves a (possibly lowercased) token back to its real key. Memoized per
|
|
178
|
-
// snapshot like keySetOf.
|
|
179
134
|
const nameKeyMapCache = new WeakMap();
|
|
180
135
|
const nameKeyMapOf = (snapshot) => {
|
|
181
136
|
let map = nameKeyMapCache.get(snapshot);
|
|
@@ -203,22 +158,8 @@ const nameBindingAffected = (tokens, keyMap, state, newState) => {
|
|
|
203
158
|
return !matched;
|
|
204
159
|
};
|
|
205
160
|
|
|
206
|
-
// Identifier-followed-by-`(` — i.e. an attempted function or method call.
|
|
207
|
-
// Used to flag conditional expressions whose result can't be trusted to the
|
|
208
|
-
// snapshot-equality short-circuit because the called function may read live
|
|
209
|
-
// `$` (or other state-holding globals) via closure rather than the state
|
|
210
|
-
// snapshot affected.js passes in. False positives are acceptable: when the
|
|
211
|
-
// expression contains a call, we fall back to "if any state key changed,
|
|
212
|
-
// re-evaluate"; mountBranch's branchChanged guard suppresses the no-op DOM
|
|
213
|
-
// churn when the value didn't actually flip.
|
|
214
161
|
const CALL_EXPR_REGEX = /\b[A-Za-z_$][\w$]*\s*\(/;
|
|
215
162
|
|
|
216
|
-
// Descend into any inlined `<component>` parsed trees attached to an
|
|
217
|
-
// iteration row. `_vibeIterTree` is stamped on the post-process wrapper by
|
|
218
|
-
// index.js (mutation-observer path) and on nested inlined components reached
|
|
219
|
-
// via `[data-vibe-iter-prop]`. The trees retain the original `@[…]` binding
|
|
220
|
-
// text so this affected walk can flag changes the same way it would on the
|
|
221
|
-
// row's own tree.
|
|
222
163
|
const walkInlinedComponentTrees = (
|
|
223
164
|
clonedNodes,
|
|
224
165
|
state,
|
|
@@ -248,16 +189,6 @@ const walkInlinedComponentTrees = (
|
|
|
248
189
|
}
|
|
249
190
|
};
|
|
250
191
|
|
|
251
|
-
// One affected() pass can reach the same parsed tree through several linkage
|
|
252
|
-
// channels — a conditional walks both its runtime-mounted branch and its
|
|
253
|
-
// processMutations-linked children, an iteration row's inlined component tree
|
|
254
|
-
// hangs off the wrapper's `_vibeIterTree` AND off an ancestor's children. Each
|
|
255
|
-
// duplicate walk multiplies at every nesting level (measured ~1000 visits per
|
|
256
|
-
// node per flush on the game's armory page). A tree object is one DOM position
|
|
257
|
-
// — the first visit already collected its entries, so the rest are skipped.
|
|
258
|
-
// Scoped states stay correct: a row-scoped tree is reached through its owning
|
|
259
|
-
// iteration first (document order), and inlined component trees carry
|
|
260
|
-
// registry-rewritten expressions that evaluate identically from any channel.
|
|
261
192
|
let walkVisited = null;
|
|
262
193
|
|
|
263
194
|
const recursive = (
|
|
@@ -272,7 +203,6 @@ const recursive = (
|
|
|
272
203
|
if (walkVisited.has(tree)) return affected;
|
|
273
204
|
walkVisited.add(tree);
|
|
274
205
|
}
|
|
275
|
-
// Handle iteration nodes specially
|
|
276
206
|
if (tree.type === "iteration") {
|
|
277
207
|
const resolvedExpr = resolveThisPath(
|
|
278
208
|
tree.meta.arrayPath,
|
|
@@ -282,9 +212,6 @@ const recursive = (
|
|
|
282
212
|
const oldArray =
|
|
283
213
|
evalInScope(resolvedExpr, state, tree.meta.startComment?.parentElement) ??
|
|
284
214
|
resolvePath(state, resolvedExpr);
|
|
285
|
-
// The new-side eval is the iteration's subscription: whatever the
|
|
286
|
-
// arrayPath expression reads NOW is what should notify it. Old-side
|
|
287
|
-
// evals never track — they read the previous snapshot.
|
|
288
215
|
const iterSub = nodeSubscriberOf(tree, "iteration");
|
|
289
216
|
iterSub.lastScope = newState;
|
|
290
217
|
beginTracking(iterSub, overlayKeysOf(newState));
|
|
@@ -296,27 +223,6 @@ const recursive = (
|
|
|
296
223
|
) ?? resolvePath(newState, resolvedExpr);
|
|
297
224
|
endTracking();
|
|
298
225
|
|
|
299
|
-
// Fast path: reference comparison (arrays are typically replaced, not mutated)
|
|
300
|
-
// This avoids expensive O(n) deepEqual for large arrays.
|
|
301
|
-
//
|
|
302
|
-
// Call-expression array paths (`<!-- each filterItems(...) -->`, `.filter()`
|
|
303
|
-
// chains) build a FRESH array on every eval, so the two evals above never
|
|
304
|
-
// share a reference even when nothing relevant changed — the ref compare
|
|
305
|
-
// alone would flag every such iteration as a full array change on every
|
|
306
|
-
// flush and rewalk all its rows (the armory 900ms-per-tick hang). For call
|
|
307
|
-
// expressions only, same length + same item refs ⇒ the rendered rows are
|
|
308
|
-
// already right: fall through to the per-instance walk below, which still
|
|
309
|
-
// catches outer-state bindings inside rows. Plain paths keep pure ref
|
|
310
|
-
// semantics — `$.items = [...$.items]` after an in-place item mutation must
|
|
311
|
-
// still refresh every row (the documented reassign-to-rerender pattern).
|
|
312
|
-
//
|
|
313
|
-
// "Already right" additionally requires the RENDERED rows to correspond to
|
|
314
|
-
// the array: an iteration whose initial render evaluated before its
|
|
315
|
-
// component's script settled (a fetched compiled fragment defining the
|
|
316
|
-
// helper) bailed with zero instances — its two evals now agree ("2, 6"
|
|
317
|
-
// literals produce equal fresh arrays every flush) but nothing is on
|
|
318
|
-
// screen, and skipping here would strand it empty forever (the game's
|
|
319
|
-
// "# Teams" BoxRange). Instance count vs array length is that invariant.
|
|
320
226
|
if (oldArray !== newArray) {
|
|
321
227
|
const freshRefArtifact =
|
|
322
228
|
CALL_EXPR_REGEX.test(resolvedExpr) &&
|
|
@@ -337,17 +243,7 @@ const recursive = (
|
|
|
337
243
|
}
|
|
338
244
|
}
|
|
339
245
|
|
|
340
|
-
// Array didn't change, but check for affected elements inside iteration instances
|
|
341
|
-
// (e.g., when tutorialProgress changes, need to update checkmarks in menu items)
|
|
342
246
|
if (tree.runtime.instances) {
|
|
343
|
-
// Compiled iterations have instances without tree/scopedState — the batch function
|
|
344
|
-
// may reference global state keys (e.g., `selectedCategory` in a button binding).
|
|
345
|
-
// If any state key changed, trigger a rebuild so the batch function re-evaluates.
|
|
346
|
-
// Instances rendered without a per-row parsed tree — compiled (batch
|
|
347
|
-
// fn from the manifest) or runtime batch (fast path for simple
|
|
348
|
-
// templates) — can't be walked by the recursive descent below to
|
|
349
|
-
// detect nested outer-state bindings. Mark the whole iteration as
|
|
350
|
-
// affected so updateIteration runs and rebuilds the rows.
|
|
351
247
|
const hasTreelessInstances =
|
|
352
248
|
tree.runtime.instances.length > 0 &&
|
|
353
249
|
!tree.runtime.instances[0].tree;
|
|
@@ -372,17 +268,6 @@ const recursive = (
|
|
|
372
268
|
|
|
373
269
|
for (const instance of tree.runtime.instances) {
|
|
374
270
|
if (instance.tree && instance.scopedState) {
|
|
375
|
-
// Build plain-object snapshots for comparison. Spreading
|
|
376
|
-
// instance.scopedState yields its local vars (item, index) plus
|
|
377
|
-
// whatever globals were captured at scope-creation time; the
|
|
378
|
-
// overlaying spread of state/newState then writes the *current*
|
|
379
|
-
// values from this update cycle. Both merged states are plain
|
|
380
|
-
// objects with up-to-date values.
|
|
381
|
-
// The merged snapshot only needs this row's aliases overlaid on the
|
|
382
|
-
// current globals (`state`/`newState` already carry every global key).
|
|
383
|
-
// Recover the small overlay recorded at scope creation and plain-merge
|
|
384
|
-
// it — spreading instance.scopedState (a Proxy) instead fires its traps
|
|
385
|
-
// over every global key, per instance, per frame (a combat hot spot).
|
|
386
271
|
const overlay = scopedOverlayOf(instance.scopedState);
|
|
387
272
|
const mergedOldState = overlay
|
|
388
273
|
? { ...state, ...overlay }
|
|
@@ -390,21 +275,10 @@ const recursive = (
|
|
|
390
275
|
const mergedNewState = overlay
|
|
391
276
|
? { ...newState, ...overlay }
|
|
392
277
|
: { ...instance.scopedState, ...newState };
|
|
393
|
-
// Carry the alias set onto the merged plain snapshots so tracking
|
|
394
|
-
// windows opened against them can tell alias reads from global
|
|
395
|
-
// reads (skipKeys). Without the recorded overlay (legacy scoped
|
|
396
|
-
// states) alias names subscribe as phantom keys — harmless, they
|
|
397
|
-
// never appear in changedProps.
|
|
398
278
|
if (overlay) {
|
|
399
279
|
rememberOverlay(mergedOldState, overlay);
|
|
400
280
|
rememberOverlay(mergedNewState, overlay);
|
|
401
281
|
}
|
|
402
|
-
// scopedStateForHydration must reflect the new state so hydrate's
|
|
403
|
-
// bindings inside the row see post-update values. instance.scopedState
|
|
404
|
-
// is frozen against whatever target renderIteration was called with
|
|
405
|
-
// (often the plain initialState snapshot from index.js), so reading
|
|
406
|
-
// outer-state keys through it returns stale values after later
|
|
407
|
-
// mutations. mergedNewState carries the live globals + localVars.
|
|
408
282
|
recursive(
|
|
409
283
|
instance.tree,
|
|
410
284
|
mergedOldState,
|
|
@@ -414,16 +288,6 @@ const recursive = (
|
|
|
414
288
|
depth + 1,
|
|
415
289
|
);
|
|
416
290
|
|
|
417
|
-
// Components mounted as `<component src>` *inside* an iteration row
|
|
418
|
-
// are not children of `instance.tree` — their parsed tree lives on
|
|
419
|
-
// the post-process wrapper as `_vibeIterTree`. Without descending
|
|
420
|
-
// into it, conditionals / bindings inside the inlined component are
|
|
421
|
-
// invisible to this walk, so a global state change that should tear
|
|
422
|
-
// down a row-internal `<!-- if -->` (or flip a binding) inside the
|
|
423
|
-
// component is missed. `updateInstance` re-hydrates these trees via
|
|
424
|
-
// `hydrateInlinedIterationComponents`, but only when the iteration
|
|
425
|
-
// itself is flagged affected (array change). For pure global-state
|
|
426
|
-
// updates the array is unchanged, so we descend here instead.
|
|
427
291
|
if (instance.clonedNodes) {
|
|
428
292
|
walkInlinedComponentTrees(
|
|
429
293
|
instance.clonedNodes,
|
|
@@ -440,16 +304,12 @@ const recursive = (
|
|
|
440
304
|
return affected;
|
|
441
305
|
}
|
|
442
306
|
|
|
443
|
-
// Handle conditional nodes specially
|
|
444
307
|
if (tree.type === "conditional") {
|
|
445
308
|
const oldValue = evaluateCondition(
|
|
446
309
|
tree.meta.expression,
|
|
447
310
|
state,
|
|
448
311
|
tree.meta.startComment?.parentElement,
|
|
449
312
|
);
|
|
450
|
-
// New-side eval doubles as the conditional's subscription (see the
|
|
451
|
-
// iteration note above). Helper bodies reading live `$` record through
|
|
452
|
-
// the proxy trap even though the direct reads here hit the snapshot.
|
|
453
313
|
const condSub = nodeSubscriberOf(tree, "conditional");
|
|
454
314
|
condSub.lastScope = newState;
|
|
455
315
|
beginTracking(condSub, overlayKeysOf(newState));
|
|
@@ -460,15 +320,7 @@ const recursive = (
|
|
|
460
320
|
);
|
|
461
321
|
endTracking();
|
|
462
322
|
|
|
463
|
-
// Check if condition result changed
|
|
464
323
|
if (oldValue !== newValue) {
|
|
465
|
-
// Carry the state pair the condition was evaluated against. Inside an
|
|
466
|
-
// iteration row this is the merged state (loop alias + current globals),
|
|
467
|
-
// outside it is the cycle's top-level state. hydrate hands these to
|
|
468
|
-
// updateConditional so the branch swap re-evaluates the expression in
|
|
469
|
-
// the same scope — without them, a row-internal `if` is re-evaluated
|
|
470
|
-
// against a state with no `item`, which always reads falsy and leaves
|
|
471
|
-
// rows that were initially on the else branch stuck there.
|
|
472
324
|
affected.push({
|
|
473
325
|
type: "conditional",
|
|
474
326
|
node: tree,
|
|
@@ -477,21 +329,9 @@ const recursive = (
|
|
|
477
329
|
oldScopedState: state,
|
|
478
330
|
wasConnected: !!tree.meta.startComment?.isConnected,
|
|
479
331
|
});
|
|
480
|
-
// The branch swap re-renders everything beneath — no point collecting
|
|
481
|
-
// entries for content that's about to be replaced.
|
|
482
332
|
return affected;
|
|
483
333
|
}
|
|
484
334
|
|
|
485
|
-
// Function-call short-circuit: when the expression contains a `(`-style
|
|
486
|
-
// call, the snapshot eval can be blind — the called helper may read live
|
|
487
|
-
// `$` via closure rather than the passed state, so `oldValue` and
|
|
488
|
-
// `newValue` both run against the *current* values and the equality check
|
|
489
|
-
// never fires. Flag as affected whenever any state key actually changed
|
|
490
|
-
// this cycle so updateConditional re-runs against live state; if the
|
|
491
|
-
// result hasn't really flipped, mountBranch's branchChanged guard absorbs
|
|
492
|
-
// the call cheaply. Skip during initial hydration (state === newState).
|
|
493
|
-
// Does NOT stop the walk: the branch is (almost always) unchanged, so the
|
|
494
|
-
// content beneath still needs its own entries collected below.
|
|
495
335
|
if (state !== newState && CALL_EXPR_REGEX.test(tree.meta.expression)) {
|
|
496
336
|
const stateKeys = Object.keys(state);
|
|
497
337
|
const anyChanged = stateKeys.some((k) => state[k] !== newState[k]);
|
|
@@ -507,17 +347,6 @@ const recursive = (
|
|
|
507
347
|
}
|
|
508
348
|
}
|
|
509
349
|
|
|
510
|
-
// Condition didn't change: walk the live content. Runtime-mounted
|
|
511
|
-
// branches live in runtime.activeInstance.parsedTree. COMPILED
|
|
512
|
-
// pre-rendered conditionals never went through mountBranch — their live
|
|
513
|
-
// DOM is what parse put in `children` — and processMutations links
|
|
514
|
-
// later-mounted subtrees (SPA fragments, slot-projected content) into
|
|
515
|
-
// `children` too. Both channels must be walked or content under a
|
|
516
|
-
// compiled conditional is frozen at its mount-time render (the game's
|
|
517
|
-
// blank fragment pages). Children whose root isn't connected are the
|
|
518
|
-
// detached both-branch template nodes of a runtime conditional — skip
|
|
519
|
-
// those; their restoration templates are strings on meta.branches, and
|
|
520
|
-
// hydrating detached template DOM is pure waste.
|
|
521
350
|
if (tree.runtime.activeInstance && tree.runtime.activeInstance.parsedTree) {
|
|
522
351
|
recursive(
|
|
523
352
|
tree.runtime.activeInstance.parsedTree,
|
|
@@ -544,9 +373,6 @@ const recursive = (
|
|
|
544
373
|
const matches = textBindingsOf(tree);
|
|
545
374
|
|
|
546
375
|
if (matches.length) {
|
|
547
|
-
// A text node is hydrated as a whole (every @[…] re-interpolated together),
|
|
548
|
-
// so the node is all-or-nothing: if any of its bindings is affected, push
|
|
549
|
-
// them all. Initial hydration affects everything.
|
|
550
376
|
const isInitialHydration = state === newState;
|
|
551
377
|
let hasAffected = isInitialHydration;
|
|
552
378
|
if (!isInitialHydration) {
|
|
@@ -566,16 +392,15 @@ const recursive = (
|
|
|
566
392
|
matchInner: m.inner,
|
|
567
393
|
input: m.input,
|
|
568
394
|
element: tree.element,
|
|
569
|
-
textNode: tree.textNode,
|
|
570
|
-
scopedState: scopedStateForHydration,
|
|
571
|
-
binding: m,
|
|
395
|
+
textNode: tree.textNode,
|
|
396
|
+
scopedState: scopedStateForHydration,
|
|
397
|
+
binding: m,
|
|
572
398
|
wasConnected: !!(tree.textNode ?? tree.element)?.isConnected,
|
|
573
399
|
});
|
|
574
400
|
}
|
|
575
401
|
}
|
|
576
402
|
}
|
|
577
403
|
|
|
578
|
-
// Check attribute bindings
|
|
579
404
|
if (tree.attributes) {
|
|
580
405
|
const isInitialHydration = state === newState;
|
|
581
406
|
const keySet = isInitialHydration ? null : keySetOf(newState);
|
|
@@ -592,9 +417,9 @@ const recursive = (
|
|
|
592
417
|
attrName,
|
|
593
418
|
attrValue,
|
|
594
419
|
matchOuter: m.outer,
|
|
595
|
-
matchInner: m.inner,
|
|
420
|
+
matchInner: m.inner,
|
|
596
421
|
element: tree.element,
|
|
597
|
-
scopedState: scopedStateForHydration,
|
|
422
|
+
scopedState: scopedStateForHydration,
|
|
598
423
|
binding: m,
|
|
599
424
|
wasConnected: !!tree.element?.isConnected,
|
|
600
425
|
});
|
|
@@ -603,7 +428,6 @@ const recursive = (
|
|
|
603
428
|
}
|
|
604
429
|
}
|
|
605
430
|
|
|
606
|
-
// Check name bindings (bindings in attribute names)
|
|
607
431
|
if (tree.nameBindings) {
|
|
608
432
|
const isInitialHydration = state === newState;
|
|
609
433
|
const keyMap = isInitialHydration ? null : nameKeyMapOf(newState);
|
|
@@ -617,7 +441,7 @@ const recursive = (
|
|
|
617
441
|
type: "nameBinding",
|
|
618
442
|
nameBinding: m.nameBinding,
|
|
619
443
|
matchOuter: m.outer,
|
|
620
|
-
matchInner: m.inner,
|
|
444
|
+
matchInner: m.inner,
|
|
621
445
|
element: tree.element,
|
|
622
446
|
scopedState: scopedStateForHydration,
|
|
623
447
|
binding: m,
|
|
@@ -627,9 +451,6 @@ const recursive = (
|
|
|
627
451
|
}
|
|
628
452
|
}
|
|
629
453
|
|
|
630
|
-
// Process children — except under an outgoing wrapper, whose subtree is
|
|
631
|
-
// frozen until the swap (the node's own bindings above stay live: the next
|
|
632
|
-
// navigation must still re-trigger the remount through them).
|
|
633
454
|
const children = tree.children;
|
|
634
455
|
if (children && !isOutgoing(tree.element)) {
|
|
635
456
|
for (const key in children) {
|
|
@@ -650,10 +471,6 @@ const recursive = (
|
|
|
650
471
|
return affected;
|
|
651
472
|
};
|
|
652
473
|
|
|
653
|
-
// The whole collect is a speculative context: evaluations here probe "did
|
|
654
|
-
// this change?" against snapshots where scoped aliases may legitimately be
|
|
655
|
-
// absent — expected throws, not author errors (debug.js reportEvalError
|
|
656
|
-
// stays quiet inside).
|
|
657
474
|
export default (tree, state, newState) => {
|
|
658
475
|
walkVisited = new WeakSet();
|
|
659
476
|
beginSpeculative();
|
package/runtime/cleanup.js
CHANGED
|
@@ -1,80 +1,40 @@
|
|
|
1
1
|
import { debugLog } from './debug.js';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}
|
|
2
|
+
import { isInert } from './inert.js';
|
|
3
|
+
import { PHASE_READY, FOUC_CLASS_OR_ATTR } from './constants.js';
|
|
4
|
+
|
|
5
|
+
const skipInert = (root) => ({
|
|
6
|
+
acceptNode: (node) => (isInert(node, root) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT),
|
|
7
|
+
});
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* Check if all Vibe processing is complete and cleanup can run
|
|
11
|
-
* @param {Element} rootElement - Root element to check
|
|
12
|
-
* @returns {Boolean} - true if cleanup should run
|
|
13
|
-
*/
|
|
14
9
|
export const shouldCleanup = (rootElement) => {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
const componentElements = [
|
|
11
|
+
...rootElement.querySelectorAll('component[src], div.component[src]'),
|
|
12
|
+
].filter((el) => !isInert(el, rootElement));
|
|
18
13
|
if (componentElements.length > 0) {
|
|
19
14
|
return false;
|
|
20
15
|
}
|
|
21
16
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
let parent = node.parentElement;
|
|
28
|
-
while (parent && parent !== rootElement) {
|
|
29
|
-
if (NON_REACTIVE_ELEMENTS.includes(parent.nodeName)) {
|
|
30
|
-
return NodeFilter.FILTER_REJECT;
|
|
31
|
-
}
|
|
32
|
-
if (parent.hasAttribute(DEHYDRATE_CLASS_OR_ATTR) || parent.classList?.contains(DEHYDRATE_CLASS_OR_ATTR)) {
|
|
33
|
-
return NodeFilter.FILTER_REJECT; // Skip dehydrated content
|
|
34
|
-
}
|
|
35
|
-
parent = parent.parentElement;
|
|
36
|
-
}
|
|
37
|
-
return NodeFilter.FILTER_ACCEPT;
|
|
38
|
-
},
|
|
39
|
-
});
|
|
17
|
+
const walker = document.createTreeWalker(
|
|
18
|
+
rootElement,
|
|
19
|
+
NodeFilter.SHOW_TEXT,
|
|
20
|
+
skipInert(rootElement),
|
|
21
|
+
);
|
|
40
22
|
|
|
41
23
|
let node;
|
|
42
24
|
while ((node = walker.nextNode())) {
|
|
43
|
-
if (/@\[.+?\]/.test(node.textContent)) {
|
|
44
|
-
return false;
|
|
25
|
+
if (!node._vibeBoundValue && /@\[.+?\]/.test(node.textContent)) {
|
|
26
|
+
return false;
|
|
45
27
|
}
|
|
46
28
|
}
|
|
47
29
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
// raw bindings across remounts, and src/key on a component wrapper are the
|
|
54
|
-
// wrapper's own remount contract — both excluded.
|
|
55
|
-
const elementWalker = document.createTreeWalker(rootElement, NodeFilter.SHOW_ELEMENT, {
|
|
56
|
-
acceptNode(el) {
|
|
57
|
-
let parent = el;
|
|
58
|
-
while (parent && parent !== rootElement) {
|
|
59
|
-
if (NON_REACTIVE_ELEMENTS.includes(parent.nodeName)) {
|
|
60
|
-
return NodeFilter.FILTER_REJECT;
|
|
61
|
-
}
|
|
62
|
-
if (parent.hasAttribute(DEHYDRATE_CLASS_OR_ATTR) || parent.classList?.contains(DEHYDRATE_CLASS_OR_ATTR)) {
|
|
63
|
-
return NodeFilter.FILTER_REJECT;
|
|
64
|
-
}
|
|
65
|
-
parent = parent.parentElement;
|
|
66
|
-
}
|
|
67
|
-
return NodeFilter.FILTER_ACCEPT;
|
|
68
|
-
},
|
|
69
|
-
});
|
|
30
|
+
const elementWalker = document.createTreeWalker(
|
|
31
|
+
rootElement,
|
|
32
|
+
NodeFilter.SHOW_ELEMENT,
|
|
33
|
+
skipInert(rootElement),
|
|
34
|
+
);
|
|
70
35
|
|
|
71
36
|
let el;
|
|
72
37
|
while ((el = elementWalker.nextNode())) {
|
|
73
|
-
// A component wrapper's attributes are all its own machinery: src/key
|
|
74
|
-
// are the remount contract, data-vibe-* is transport, and every other
|
|
75
|
-
// attribute is a PROP — owned and consumed by processComponent at mount,
|
|
76
|
-
// parked raw by design on a declaration-form outlet whose src resolved
|
|
77
|
-
// to nothing. None of them are unhydrated page bindings.
|
|
78
38
|
if (
|
|
79
39
|
el.nodeName === 'COMPONENT' ||
|
|
80
40
|
(el.nodeName === 'DIV' && el.classList?.contains('component'))
|
|
@@ -83,36 +43,27 @@ export const shouldCleanup = (rootElement) => {
|
|
|
83
43
|
}
|
|
84
44
|
for (const attr of el.attributes) {
|
|
85
45
|
if (attr.name.startsWith('data-vibe-')) continue;
|
|
46
|
+
if (el._vibeBoundAttrs?.has(attr.name)) continue;
|
|
86
47
|
if (/@\[.+?\]/.test(attr.value)) {
|
|
87
48
|
return false;
|
|
88
49
|
}
|
|
89
50
|
}
|
|
90
51
|
}
|
|
91
52
|
|
|
92
|
-
// 4. All processing appears complete
|
|
93
53
|
return true;
|
|
94
54
|
};
|
|
95
55
|
|
|
96
|
-
/**
|
|
97
|
-
* Perform cleanup - remove attribute or class from all matching elements
|
|
98
|
-
* @param {Element} rootElement - Root element
|
|
99
|
-
* @param {Boolean} debug - Debug mode
|
|
100
|
-
*/
|
|
101
56
|
export const cleanup = (rootElement, debug = false) => {
|
|
102
|
-
// Force reflow
|
|
103
57
|
rootElement.offsetHeight;
|
|
104
58
|
|
|
105
|
-
// Determine if selector is a class (starts with .) or attribute (default)
|
|
106
59
|
const isClass = FOUC_CLASS_OR_ATTR.startsWith('.');
|
|
107
60
|
const cleanName = FOUC_CLASS_OR_ATTR.replace(/^\./, '').replace(/^\[/, '').replace(/\]$/, '');
|
|
108
61
|
|
|
109
62
|
if (isClass) {
|
|
110
|
-
// Remove class from all elements in document that have it
|
|
111
63
|
const elements = document.querySelectorAll(`.${cleanName}`);
|
|
112
64
|
elements.forEach((el) => el.classList.remove(cleanName));
|
|
113
65
|
debugLog(PHASE_READY, `Removing .${cleanName} class from ${elements.length} ${elements.length === 1 ? 'element' : 'elements'}`, debug);
|
|
114
66
|
} else {
|
|
115
|
-
// Remove attribute from all elements in document that have it
|
|
116
67
|
const elements = document.querySelectorAll(`[${cleanName}]`);
|
|
117
68
|
elements.forEach((el) => el.removeAttribute(cleanName));
|
|
118
69
|
debugLog(
|
|
@@ -1,39 +1,7 @@
|
|
|
1
|
-
// Component template cache.
|
|
2
|
-
//
|
|
3
|
-
// Vibe loads each `<component src="...">` by fetching its HTML template. A page
|
|
4
|
-
// commonly mounts the same component many times (a list of cards, a row of
|
|
5
|
-
// stat bars), and an SPA re-mounts components on every navigation. Without a
|
|
6
|
-
// cache, each instance — and each revisit — refetches an identical template,
|
|
7
|
-
// and a burst of same-tick mounts stampedes the network with N concurrent
|
|
8
|
-
// requests for one file.
|
|
9
|
-
//
|
|
10
|
-
// This module dedupes those fetches by `src`:
|
|
11
|
-
// - concurrent mounts in the same tick share one in-flight request, because
|
|
12
|
-
// the PROMISE (not the resolved text) is cached synchronously before the
|
|
13
|
-
// first await — so callers coalesce onto it instead of each starting their own
|
|
14
|
-
// - later mounts (including after SPA navigation) resolve from memory, with
|
|
15
|
-
// no network request at all — the one win a browser HTTP cache cannot
|
|
16
|
-
// provide, since it revalidates per request and never coalesces concurrent ones
|
|
17
|
-
//
|
|
18
|
-
// The cache is session-lived and content-busted, never time-busted. In
|
|
19
|
-
// production a component template is immutable for the life of the page (it
|
|
20
|
-
// only changes on redeploy, which is a new session anyway), so there is nothing
|
|
21
|
-
// to invalidate. In development, tooling busts entries on file change via
|
|
22
|
-
// `clearComponentCache(path)` — which keeps this module free of any dev/HMR
|
|
23
|
-
// coupling; it never references the dev server or its events.
|
|
24
|
-
//
|
|
25
|
-
// Disable entirely with `vibe(state, { noCache: true })`.
|
|
26
|
-
|
|
27
1
|
let enabled = true;
|
|
28
2
|
|
|
29
|
-
// src -> Promise<string> (raw template HTML). Stores the in-flight promise so
|
|
30
|
-
// concurrent callers coalesce; the resolved value is held by the promise, so a
|
|
31
|
-
// settled entry is an instant cache hit on every later read.
|
|
32
3
|
const templates = new Map();
|
|
33
4
|
|
|
34
|
-
// Configure from the runtime config (`{ noCache }`). Called once at boot. When
|
|
35
|
-
// caching is turned off we also drop anything already cached, so toggling at
|
|
36
|
-
// runtime (e.g. between test cases) can't serve a stale hit.
|
|
37
5
|
export const configureComponentCache = (config = {}) => {
|
|
38
6
|
enabled = !config?.noCache;
|
|
39
7
|
if (!enabled) templates.clear();
|
|
@@ -41,19 +9,8 @@ export const configureComponentCache = (config = {}) => {
|
|
|
41
9
|
|
|
42
10
|
export const isComponentCacheEnabled = () => enabled;
|
|
43
11
|
|
|
44
|
-
// True when `src` will resolve without a new network request — either a settled
|
|
45
|
-
// template or an in-flight request this mount coalesces onto. Callers capture
|
|
46
|
-
// this BEFORE fetchComponentTemplate so the debug layer can distinguish a real
|
|
47
|
-
// network fetch from a cache hit.
|
|
48
12
|
export const isComponentCached = (src) => enabled && templates.has(src);
|
|
49
13
|
|
|
50
|
-
// Fetch a component template, deduped by `src`. Returns a Promise<string>.
|
|
51
|
-
//
|
|
52
|
-
// `signal` aborts the request when the host element is removed. It is honored
|
|
53
|
-
// only on the uncached path: a shared cached fetch must NOT be aborted by one
|
|
54
|
-
// element unmounting while other elements still await the same template. The
|
|
55
|
-
// caller already re-checks `el.parentNode` after the fetch settles, so dropping
|
|
56
|
-
// the abort on the shared path costs nothing but a tiny, redundant download.
|
|
57
14
|
export const fetchComponentTemplate = (src, signal) => {
|
|
58
15
|
if (!enabled) {
|
|
59
16
|
return fetch(src, { signal }).then((response) => response.text());
|
|
@@ -63,25 +20,15 @@ export const fetchComponentTemplate = (src, signal) => {
|
|
|
63
20
|
if (!entry) {
|
|
64
21
|
entry = fetch(src).then(async (response) => {
|
|
65
22
|
const text = await response.text();
|
|
66
|
-
// Never persist a failed response — the immediate caller still gets the
|
|
67
|
-
// body (parity with the uncached path), but the next mount may retry.
|
|
68
23
|
if (!response.ok) templates.delete(src);
|
|
69
24
|
return text;
|
|
70
25
|
});
|
|
71
|
-
// Cache synchronously, before the first await, so same-tick concurrent
|
|
72
|
-
// mounts find this pending entry and coalesce onto it.
|
|
73
26
|
templates.set(src, entry);
|
|
74
|
-
// Drop the entry if the fetch rejects, so a transient network error isn't
|
|
75
|
-
// sticky for the rest of the session.
|
|
76
27
|
entry.catch(() => templates.delete(src));
|
|
77
28
|
}
|
|
78
29
|
return entry;
|
|
79
30
|
};
|
|
80
31
|
|
|
81
|
-
// Invalidate cached templates. With a `path`, drops that one entry (the query
|
|
82
|
-
// string is ignored when matching, so `/components/Foo.html` also clears a
|
|
83
|
-
// versioned `/components/Foo.html?v=…`); with no argument, clears everything.
|
|
84
|
-
// Exposed publicly as `$.clearComponentCache` for tooling to call on change.
|
|
85
32
|
export const clearComponentCache = (path) => {
|
|
86
33
|
if (!path) {
|
|
87
34
|
templates.clear();
|