@ape-egg/vibe 2.3.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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/parse.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
findEndComment,
|
|
3
|
+
findEndCommentIndex,
|
|
4
|
+
findConditionalEnd,
|
|
5
|
+
parseIterationHeader,
|
|
6
|
+
} from './iteration-utils.js';
|
|
2
7
|
import {
|
|
3
8
|
NON_REACTIVE_ELEMENTS,
|
|
4
9
|
BINDING_REGEX,
|
|
5
10
|
CONDITIONAL_REGEX,
|
|
11
|
+
ITERATION_START_REGEX,
|
|
6
12
|
DOM_ELEMENT_PROPERTIES,
|
|
7
13
|
DEHYDRATE_CLASS_OR_ATTR,
|
|
8
14
|
THIS_PROP_REGEX,
|
|
@@ -18,6 +24,18 @@ const findComponentIdForElement = (element) => {
|
|
|
18
24
|
return wrapper ? wrapper.getAttribute('data-vibe-component-id') : null;
|
|
19
25
|
};
|
|
20
26
|
|
|
27
|
+
// `key` is reserved on fetched-component wrappers and remounts on CHANGE — a
|
|
28
|
+
// literal key can never change, so it can never fire. Warn once per value
|
|
29
|
+
// instead of dying silently (reparses would otherwise spam).
|
|
30
|
+
const warnedStaticKeys = new Set();
|
|
31
|
+
const warnStaticKey = (key) => {
|
|
32
|
+
if (warnedStaticKeys.has(key)) return;
|
|
33
|
+
warnedStaticKeys.add(key);
|
|
34
|
+
console.warn(
|
|
35
|
+
`[vibe] <component key="${key}"> is static — key remounts on CHANGE, so a literal never fires. Bind it (key="@[expr]") or remove it.`,
|
|
36
|
+
);
|
|
37
|
+
};
|
|
38
|
+
|
|
21
39
|
// Single source of truth for reading attribute/name bindings off an element.
|
|
22
40
|
// Called from both the root handler and recursive() so they can't drift. Any
|
|
23
41
|
// element classified as a fetched component (`<component src>` or
|
|
@@ -44,6 +62,7 @@ const captureAttributeBindings = (element, aliasSet) => {
|
|
|
44
62
|
if (BINDING_REGEX.test(src)) attributes.src = src;
|
|
45
63
|
BINDING_REGEX.lastIndex = 0;
|
|
46
64
|
if (key && BINDING_REGEX.test(key)) attributes.key = key;
|
|
65
|
+
else if (key) warnStaticKey(key);
|
|
47
66
|
return {
|
|
48
67
|
attributes: Object.keys(attributes).length ? attributes : null,
|
|
49
68
|
nameBindings: null,
|
|
@@ -169,7 +188,39 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
169
188
|
|
|
170
189
|
// Handle iteration comments
|
|
171
190
|
if (nodeName === '#comment') {
|
|
172
|
-
const
|
|
191
|
+
const trimmedComment = textContent.trim();
|
|
192
|
+
const iterationMatch = parseIterationHeader(trimmedComment);
|
|
193
|
+
|
|
194
|
+
// An each-shaped comment that fails the grammar, WITH a matching /each,
|
|
195
|
+
// is an authored directive with a syntax error — code, not prose. It
|
|
196
|
+
// used to render its body once, silently: the framework's worst
|
|
197
|
+
// footgun. Directives scream like a SyntaxError in every mode. (A
|
|
198
|
+
// prose comment merely starting with "each", no /each below, stays an
|
|
199
|
+
// inert comment.)
|
|
200
|
+
if (
|
|
201
|
+
!iterationMatch &&
|
|
202
|
+
ITERATION_START_REGEX.test(trimmedComment) &&
|
|
203
|
+
findEndCommentIndex(children, i + 1) !== -1
|
|
204
|
+
) {
|
|
205
|
+
console.error(
|
|
206
|
+
`[vibe] Malformed each directive: <!-- ${trimmedComment} -->\n` +
|
|
207
|
+
'Expected <!-- each items as item -->, with optional ", index" and "(key)" in either order — e.g. <!-- each items as item (item.id), i -->',
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// A bare <!-- if --> / <!-- else if --> (no expression) with a
|
|
212
|
+
// matching /if ahead is the same class of authored error.
|
|
213
|
+
if (/^if$/.test(trimmedComment) || /^else\s+if$/.test(trimmedComment)) {
|
|
214
|
+
try {
|
|
215
|
+
findConditionalEnd(children, i + 1);
|
|
216
|
+
console.error(
|
|
217
|
+
`[vibe] Malformed ${trimmedComment.startsWith('else') ? 'else if' : 'if'} directive: <!-- ${trimmedComment} --> has no expression.\n` +
|
|
218
|
+
'Expected <!-- if condition --> ... <!-- else if condition --> ... <!-- /if -->',
|
|
219
|
+
);
|
|
220
|
+
} catch {
|
|
221
|
+
// No /if below — an inert comment.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
173
224
|
|
|
174
225
|
if (iterationMatch) {
|
|
175
226
|
const { arrayPath, itemAlias, keyExpr, indexAlias } = iterationMatch;
|
|
@@ -269,8 +320,8 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
269
320
|
const [_, expression] = conditionalMatch;
|
|
270
321
|
|
|
271
322
|
try {
|
|
272
|
-
// Find matching end comment and optional else
|
|
273
|
-
const { elseIndex, endIndex } = findConditionalEnd(children, i + 1);
|
|
323
|
+
// Find matching end comment and optional else / else-if boundary
|
|
324
|
+
const { elseIndex, endIndex, elseText } = findConditionalEnd(children, i + 1);
|
|
274
325
|
|
|
275
326
|
// Extract true branch nodes (between if and else/endif)
|
|
276
327
|
const trueBranchEnd = elseIndex !== null ? elseIndex : endIndex;
|
|
@@ -286,15 +337,28 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
286
337
|
// inside a conditional nested within an iteration).
|
|
287
338
|
const trueBranchParsed = recursive([...trueBranchContainer.childNodes], undefined, new Set(), stats, aliasSet);
|
|
288
339
|
|
|
289
|
-
// Extract false branch nodes if else exists
|
|
340
|
+
// Extract false branch nodes if else exists. An `else if` boundary
|
|
341
|
+
// DESUGARS: the false branch becomes a synthesized nested
|
|
342
|
+
// conditional (`<!-- if rest -->…<!-- /if -->` wrapping the chain
|
|
343
|
+
// tail), so every mount/patch/manifest path only ever sees binary
|
|
344
|
+
// conditionals — else-if is grammar, not machinery. The chain tail
|
|
345
|
+
// may itself contain further else-if boundaries; the recursion
|
|
346
|
+
// desugars them the same way.
|
|
290
347
|
let falseBranchParsed = null;
|
|
291
348
|
let falseBranchContainer = null;
|
|
292
349
|
if (elseIndex !== null) {
|
|
293
350
|
const falseBranchNodes = Array.from(children).slice(elseIndex + 1, endIndex);
|
|
294
351
|
falseBranchContainer = document.createElement('div');
|
|
352
|
+
const chained = elseText !== 'else';
|
|
353
|
+
if (chained) {
|
|
354
|
+
falseBranchContainer.appendChild(
|
|
355
|
+
document.createComment(` ${elseText.replace(/^else\s+/, '')} `),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
295
358
|
falseBranchNodes.forEach((node) => {
|
|
296
359
|
falseBranchContainer.appendChild(node.cloneNode(true));
|
|
297
360
|
});
|
|
361
|
+
if (chained) falseBranchContainer.appendChild(document.createComment(' /if '));
|
|
298
362
|
falseBranchParsed = recursive([...falseBranchContainer.childNodes], undefined, new Set(), stats, aliasSet);
|
|
299
363
|
}
|
|
300
364
|
|
|
@@ -125,6 +125,7 @@ export const renderCompiled = (iterationNode, array, state, compiledMeta, parent
|
|
|
125
125
|
|
|
126
126
|
parent.insertBefore(frag, endComment);
|
|
127
127
|
iterationNode.runtime.instances = instances;
|
|
128
|
+
iterationNode.runtime.lastCompiledHtml = html;
|
|
128
129
|
stampInstanceScopes(iterationNode);
|
|
129
130
|
return true;
|
|
130
131
|
}
|
|
@@ -137,9 +138,22 @@ export const renderCompiled = (iterationNode, array, state, compiledMeta, parent
|
|
|
137
138
|
*/
|
|
138
139
|
export const updateCompiled = (iterationNode, newArray, state, compiledMeta, startComment, endComment) => {
|
|
139
140
|
const parent = startComment.parentNode;
|
|
141
|
+
const rt = iterationNode.runtime;
|
|
142
|
+
|
|
143
|
+
const html = newArray.length === 0 ? '' : callCompiled(iterationNode, newArray, state, compiledMeta);
|
|
144
|
+
if (html === null) return false;
|
|
145
|
+
|
|
146
|
+
// Keep-DOM guard, same contract as the runtime batch renderer: compiled
|
|
147
|
+
// iterations sit in the always-dirty bucket, so every flush of ANY key
|
|
148
|
+
// lands here — identical output must keep the existing rows (imperative
|
|
149
|
+
// listeners, focus, markers die with deleteContents), not rebuild them.
|
|
150
|
+
if (html === rt.lastCompiledHtml && rt.instances.length === newArray.length) {
|
|
151
|
+
for (let i = 0; i < newArray.length; i++) rt.instances[i].item = newArray[i];
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
140
154
|
|
|
141
155
|
// Clear existing instances
|
|
142
|
-
if (
|
|
156
|
+
if (rt.instances.length > 0) {
|
|
143
157
|
const range = document.createRange();
|
|
144
158
|
range.setStartAfter(startComment);
|
|
145
159
|
range.setEndBefore(endComment);
|
|
@@ -147,13 +161,11 @@ export const updateCompiled = (iterationNode, newArray, state, compiledMeta, sta
|
|
|
147
161
|
}
|
|
148
162
|
|
|
149
163
|
if (newArray.length === 0) {
|
|
150
|
-
|
|
164
|
+
rt.instances = [];
|
|
165
|
+
rt.lastCompiledHtml = '';
|
|
151
166
|
return true;
|
|
152
167
|
}
|
|
153
168
|
|
|
154
|
-
const html = callCompiled(iterationNode, newArray, state, compiledMeta);
|
|
155
|
-
if (html === null) return false;
|
|
156
|
-
|
|
157
169
|
// Parse and insert
|
|
158
170
|
if (parseTemplate) {
|
|
159
171
|
parseTemplate.innerHTML = html;
|
|
@@ -168,7 +180,8 @@ export const updateCompiled = (iterationNode, newArray, state, compiledMeta, sta
|
|
|
168
180
|
}
|
|
169
181
|
|
|
170
182
|
parent.insertBefore(frag, endComment);
|
|
171
|
-
|
|
183
|
+
rt.instances = instances;
|
|
184
|
+
rt.lastCompiledHtml = html;
|
|
172
185
|
stampInstanceScopes(iterationNode);
|
|
173
186
|
return true;
|
|
174
187
|
}
|
|
@@ -252,13 +252,22 @@ const detectHyperspeed = async () => {
|
|
|
252
252
|
const skipNetwork = !!document.querySelector("[vibe-fouc], .vibe-fouc");
|
|
253
253
|
|
|
254
254
|
try {
|
|
255
|
+
// window.__MANIFEST__ is an explicit manifest URL stamped into the page by
|
|
256
|
+
// the build/dev server. The compiled SPA shell needs it: the shell is
|
|
257
|
+
// served for EVERY route, so pathname-derived candidates point at manifests
|
|
258
|
+
// that don't exist (deep links probed 404s and never found the shell's own
|
|
259
|
+
// manifest). When present it is the single, authoritative candidate.
|
|
260
|
+
//
|
|
255
261
|
// window.__ROUTE__ is the page's route template (e.g. "/brawlers/:index"),
|
|
256
262
|
// injected by the compiler/dev server for dynamic pages. It lets us resolve
|
|
257
263
|
// the tokenized `$` manifest directly instead of probing literal 404s.
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
264
|
+
const hinted = typeof window !== "undefined" ? window.__MANIFEST__ : null;
|
|
265
|
+
const possiblePaths = hinted
|
|
266
|
+
? [hinted]
|
|
267
|
+
: buildManifestCandidatePaths(
|
|
268
|
+
window.location.pathname,
|
|
269
|
+
typeof window !== "undefined" ? window.__ROUTE__ : null,
|
|
270
|
+
);
|
|
262
271
|
|
|
263
272
|
if (possiblePaths.length === 0) return null;
|
|
264
273
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Navigation staging — the one-paint commit machinery behind reactive-src /
|
|
2
|
+
// keyed remounts, owned in one place. A remount marks the mounted wrapper
|
|
3
|
+
// OUTGOING: it keeps the screen — visible, styled, frozen — while the
|
|
4
|
+
// incoming wrapper hydrates as a display:none sibling ([vibe-staged]). The
|
|
5
|
+
// commit swaps the two and replays parked ancestor bindings in one
|
|
6
|
+
// synchronous step: navigation paints exactly once, never an unstyled or
|
|
7
|
+
// blank frame between.
|
|
8
|
+
//
|
|
9
|
+
// Every consumer answers its question through THIS module:
|
|
10
|
+
// isOutgoing(el) tree walks — skip collecting under a frozen wrapper
|
|
11
|
+
// outgoingRootOf(n) dispatch — is this subscriber inside a frozen page?
|
|
12
|
+
// parkRootFor(el) hydrate — is a frozen page inside this element? then
|
|
13
|
+
// park the binding (it is the styling context AROUND
|
|
14
|
+
// the outlet — flipping it mid-stage un-styles the
|
|
15
|
+
// visible page) and let the commit replay it
|
|
16
|
+
// stage/commit/abandon component.js's mount finalize drives transitions
|
|
17
|
+
|
|
18
|
+
// The mount pipeline replaces wrappers (finalize's replaceWith), leaving a
|
|
19
|
+
// `_vibeReplacedBy` link behind. Trees and clone lists keep ORIGINAL nodes —
|
|
20
|
+
// follow the chain to the live one, compressing so intermediate detached
|
|
21
|
+
// wrappers stay collectable.
|
|
22
|
+
export const liveNode = (node) => {
|
|
23
|
+
let live = node;
|
|
24
|
+
while (live && live._vibeReplacedBy) live = live._vibeReplacedBy;
|
|
25
|
+
if (live !== node && node) node._vibeReplacedBy = live;
|
|
26
|
+
return live;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Wrappers whose mounted content is outgoing — a remount is in flight.
|
|
30
|
+
export const activeOutgoingRoots = new Set();
|
|
31
|
+
|
|
32
|
+
export const isOutgoing = (element) => !!(element && liveNode(element)._vibeOutgoing);
|
|
33
|
+
|
|
34
|
+
export const markOutgoing = (el) => {
|
|
35
|
+
el._vibeOutgoing = true;
|
|
36
|
+
activeOutgoingRoots.add(el);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const releaseOutgoing = (el) => {
|
|
40
|
+
el._vibeOutgoing = false;
|
|
41
|
+
activeOutgoingRoots.delete(el);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// The outgoing root STRICTLY containing `node`, or null. The root's own
|
|
45
|
+
// src/key/prop bindings stay live (a rapid next navigation must re-trigger);
|
|
46
|
+
// everything beneath it is replaced wholesale at the swap, so re-rendering
|
|
47
|
+
// it against post-navigation state is pure waste — and was the visible
|
|
48
|
+
// mid-navigation collapse.
|
|
49
|
+
export const outgoingRootOf = (node) => {
|
|
50
|
+
if (!activeOutgoingRoots.size || !node) return null;
|
|
51
|
+
for (const root of activeOutgoingRoots) {
|
|
52
|
+
if (root !== node && root.contains?.(node)) return root;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// The outgoing root strictly INSIDE `element`, or null — the park direction:
|
|
58
|
+
// `element` is the styling context around the outlet (the game's
|
|
59
|
+
// `<page @[page.name]>`). Zero cost when nothing is staging.
|
|
60
|
+
export const parkRootFor = (element) => {
|
|
61
|
+
if (!activeOutgoingRoots.size || !element) return null;
|
|
62
|
+
for (const root of activeOutgoingRoots) {
|
|
63
|
+
if (root !== element && element.contains?.(root)) return root;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Parked updates live on the outgoing wrapper (Map<element, Map<key, apply>>)
|
|
69
|
+
// keyed by element + binding so rapid flushes during staging keep only the
|
|
70
|
+
// latest value; the commit replays them in its synchronous swap step.
|
|
71
|
+
export const parkBinding = (root, element, key, apply) => {
|
|
72
|
+
const parked = (root._vibeParkedBindings ??= new Map());
|
|
73
|
+
let perElement = parked.get(element);
|
|
74
|
+
if (!perElement) parked.set(element, (perElement = new Map()));
|
|
75
|
+
perElement.set(key, apply);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const replayParked = (old) => {
|
|
79
|
+
const parked = old._vibeParkedBindings;
|
|
80
|
+
if (!parked) return;
|
|
81
|
+
for (const perElement of parked.values()) {
|
|
82
|
+
for (const apply of perElement.values()) apply();
|
|
83
|
+
}
|
|
84
|
+
old._vibeParkedBindings = null;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// A fetched-component host: `<component>` or `<div class="component">`.
|
|
88
|
+
export const isComponentWrapper = (el) =>
|
|
89
|
+
el.nodeName === 'COMPONENT' ||
|
|
90
|
+
(el.nodeName === 'DIV' && el.classList?.contains('component'));
|
|
91
|
+
|
|
92
|
+
// A flush entry that (re)mounts: the wrapper's own src/key binding. These
|
|
93
|
+
// process FLUSH-WIDE FIRST — they register the outgoing roots every other
|
|
94
|
+
// entry's freeze-skip and parking decisions depend on. (Tree-walk order gave
|
|
95
|
+
// the walk this for free within one pass; dispatch batches must hoist.)
|
|
96
|
+
export const isRemountTrigger = (entry) =>
|
|
97
|
+
entry.type === 'attribute' &&
|
|
98
|
+
(entry.attrName === 'src' || entry.attrName === 'key') &&
|
|
99
|
+
!!entry.element &&
|
|
100
|
+
isComponentWrapper(entry.element);
|
|
101
|
+
|
|
102
|
+
// ————— staged swap transitions (driven by component.js finalize) —————
|
|
103
|
+
|
|
104
|
+
// Mount landed for an outgoing wrapper: stage the incoming wrapper, don't
|
|
105
|
+
// swap. A superseded staged wrapper (rapid re-navigation) was never visible:
|
|
106
|
+
// drop it immediately and inherit the ORIGINAL visible page as this mount's
|
|
107
|
+
// commit target.
|
|
108
|
+
export const stageIncoming = (el, newWrapper) => {
|
|
109
|
+
if (el.hasAttribute('vibe-staged')) {
|
|
110
|
+
newWrapper._vibeCommitOld = el._vibeCommitOld;
|
|
111
|
+
activeOutgoingRoots.delete(el);
|
|
112
|
+
newWrapper.setAttribute('vibe-staged', '');
|
|
113
|
+
el.replaceWith(newWrapper);
|
|
114
|
+
} else {
|
|
115
|
+
newWrapper._vibeCommitOld = el;
|
|
116
|
+
newWrapper.setAttribute('vibe-staged', '');
|
|
117
|
+
// The outgoing wrapper is no longer a mount request — with the src
|
|
118
|
+
// attribute still on it, every processComponent rescan would treat it as
|
|
119
|
+
// unresolved and mount a second copy while it holds the screen.
|
|
120
|
+
el.removeAttribute('src');
|
|
121
|
+
el.after(newWrapper);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Atomic visual commit: old page out, parked styling-context bindings
|
|
126
|
+
// applied, new page revealed — one synchronous block, one paint.
|
|
127
|
+
export const commitStaged = (newWrapper) => {
|
|
128
|
+
const old = newWrapper._vibeCommitOld;
|
|
129
|
+
if (old) {
|
|
130
|
+
old.remove();
|
|
131
|
+
activeOutgoingRoots.delete(old);
|
|
132
|
+
replayParked(old);
|
|
133
|
+
newWrapper._vibeCommitOld = null;
|
|
134
|
+
}
|
|
135
|
+
newWrapper.removeAttribute('vibe-staged');
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Incoming wrapper died before its commit (unmounted mid-hydration, or
|
|
139
|
+
// removed by something outside the staging machinery — $.reconcile on an
|
|
140
|
+
// ancestor, app code pruning the container): no swap is coming. Release the
|
|
141
|
+
// frozen old page — connected, it must resume updating; detached, the Set
|
|
142
|
+
// entry would pin the subtree forever — and reverse the replaced-by link so
|
|
143
|
+
// chains compressed onto the dead staged wrapper resolve back to the page
|
|
144
|
+
// that stayed (its src binding must act on IT again, or no later navigation
|
|
145
|
+
// can ever remount).
|
|
146
|
+
export const abandonStaged = (newWrapper) => {
|
|
147
|
+
const old = newWrapper._vibeCommitOld;
|
|
148
|
+
if (!old) return;
|
|
149
|
+
newWrapper._vibeCommitOld = null;
|
|
150
|
+
releaseOutgoing(old);
|
|
151
|
+
old._vibeReplacedBy = null;
|
|
152
|
+
newWrapper._vibeReplacedBy = old;
|
|
153
|
+
};
|
package/runtime/state.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { recordRead } from './tracking.js';
|
|
2
|
+
|
|
1
3
|
// Track which objects are already proxied to avoid double-wrapping
|
|
2
4
|
const proxyCache = new WeakMap();
|
|
3
5
|
|
|
@@ -15,6 +17,29 @@ const RAW = Symbol('vibeRaw');
|
|
|
15
17
|
const unwrap = (value) =>
|
|
16
18
|
value !== null && typeof value === 'object' && value[RAW] !== undefined ? value[RAW] : value;
|
|
17
19
|
|
|
20
|
+
// Write a property onto the proxy's raw target without waking the reactive
|
|
21
|
+
// pipeline — no changedProps, no flush. For writes that reactivity can prove
|
|
22
|
+
// irrelevant: a freshly generated component id has no live bindings yet (its
|
|
23
|
+
// subtree hydrates later in the same batch by reading state directly), so
|
|
24
|
+
// flushing on registration re-walks the whole live tree for nothing. Reads
|
|
25
|
+
// through the proxy see the value immediately; later writes through the
|
|
26
|
+
// proxy flush normally.
|
|
27
|
+
export const silentSet = (proxy, prop, value) => {
|
|
28
|
+
const raw = proxy !== null && typeof proxy === 'object' ? (proxy[RAW] ?? proxy) : proxy;
|
|
29
|
+
raw[prop] = unwrap(value);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Mark a batch of already-written keys changed and schedule one flush — the
|
|
33
|
+
// commit half of silentSet. A mount registers N component states silently as
|
|
34
|
+
// its scripts settle (staggered by module fetches), then notifies ONCE, so
|
|
35
|
+
// whole-`$` observers (`@[Object.keys($)...]`) still see every fresh key
|
|
36
|
+
// with a single tree walk instead of N.
|
|
37
|
+
export const notifyChanged = (props) => {
|
|
38
|
+
if (!props || props.length === 0) return;
|
|
39
|
+
for (const prop of props) changedProps.add(prop);
|
|
40
|
+
scheduleFlush();
|
|
41
|
+
};
|
|
42
|
+
|
|
18
43
|
// Batching: collect mutations and flush once per microtask
|
|
19
44
|
let pendingFlush = false;
|
|
20
45
|
let flushCallback = null;
|
|
@@ -95,6 +120,12 @@ const createDeepProxy = (target, rerender, rootState = null, rootProp = null) =>
|
|
|
95
120
|
// recover the unproxied object from any of our proxies.
|
|
96
121
|
if (prop === RAW) return target;
|
|
97
122
|
|
|
123
|
+
// Subscription tracking: a read inside an open tracking window records
|
|
124
|
+
// the ROOT key it descended from. This is what makes helper bodies
|
|
125
|
+
// truthful dependencies — `isPremium()` reading `$.premiumUntil` via
|
|
126
|
+
// closure lands here mid-evaluation. One null check when inactive.
|
|
127
|
+
recordRead(rootProp === null ? prop : rootProp);
|
|
128
|
+
|
|
98
129
|
const value = Reflect.get(target, prop);
|
|
99
130
|
|
|
100
131
|
// Don't proxy non-objects, functions, null, or Promises.
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Auto-tracked reactive subscriptions — the knowledge half of the update
|
|
2
|
+
// engine. Each reactive obligation (a text-binding span, an attribute or name
|
|
3
|
+
// binding, a conditional, an iteration) is a SUBSCRIBER; during its
|
|
4
|
+
// evaluation a tracking window is open and every genuine state read that
|
|
5
|
+
// happens inside it — proxy get traps, evalInScope's state-first parameter
|
|
6
|
+
// resolution, `$.key` reads on snapshots, scoped-state global fallthrough —
|
|
7
|
+
// records the ROOT state key into the window. Closing the window commits the
|
|
8
|
+
// read set to a reverse index (rootKey → Set<subscriber>), replacing the
|
|
9
|
+
// subscriber's previous deps wholesale so conditional branches self-heal
|
|
10
|
+
// (`a ? $.x : $.y` re-subscribes as it flips). A write then answers "who
|
|
11
|
+
// cares?" from the index instead of walking the world.
|
|
12
|
+
//
|
|
13
|
+
// Granularity is deliberately root-key only — the same granularity as
|
|
14
|
+
// state.js's changedProps. Recording is active ONLY inside a window: app
|
|
15
|
+
// code (handlers, boot scripts, intervals) never tracks, and the inactive
|
|
16
|
+
// path is a single null check.
|
|
17
|
+
|
|
18
|
+
// Stack of open windows — nesting is tolerated (a subscriber's eval that
|
|
19
|
+
// transitively evaluates another registers each read against the window that
|
|
20
|
+
// was open when it happened), though engine call sites keep windows tight
|
|
21
|
+
// around single evaluations.
|
|
22
|
+
let windows = [];
|
|
23
|
+
let active = null;
|
|
24
|
+
|
|
25
|
+
// rootKey → Set<subscriber>
|
|
26
|
+
const keyIndex = new Map();
|
|
27
|
+
// Conservative bucket: subscribers whose evaluation read NO tracked key at
|
|
28
|
+
// all (module-level caches, Date.now(), Object.keys($) enumeration). Can't
|
|
29
|
+
// prove what they depend on, so they re-evaluate on every flush — the same
|
|
30
|
+
// contract the walk's "no known state key ⇒ affected" check gives today.
|
|
31
|
+
const alwaysSubs = new Set();
|
|
32
|
+
// subscriber → its committed dep Set (empty Set = scope-only, owned by its
|
|
33
|
+
// iteration's diff; absent = never tracked/unsubscribed)
|
|
34
|
+
const subDeps = new Map();
|
|
35
|
+
|
|
36
|
+
// Open a window for `sub`. `skipKeys` names the scope-alias keys of the
|
|
37
|
+
// evaluation state (loop `item`/`index` overlays) — reads of those are the
|
|
38
|
+
// row's own scope, owned by the iteration's diff lifecycle, so they mark the
|
|
39
|
+
// window scope-touched instead of subscribing.
|
|
40
|
+
export const beginTracking = (sub, skipKeys = null) => {
|
|
41
|
+
active = { sub, skipKeys, reads: new Set(), absent: new Set(), sawScope: false };
|
|
42
|
+
windows.push(active);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const endTracking = () => {
|
|
46
|
+
const w = windows.pop();
|
|
47
|
+
active = windows.length ? windows[windows.length - 1] : null;
|
|
48
|
+
if (w) commit(w);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const dropFromBuckets = (sub, deps) => {
|
|
52
|
+
for (const key of deps) {
|
|
53
|
+
const bucket = keyIndex.get(key);
|
|
54
|
+
if (bucket) {
|
|
55
|
+
bucket.delete(sub);
|
|
56
|
+
if (bucket.size === 0) keyIndex.delete(key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const commit = ({ sub, reads, absent, sawScope }) => {
|
|
62
|
+
const prev = subDeps.get(sub);
|
|
63
|
+
if (prev) dropFromBuckets(sub, prev);
|
|
64
|
+
alwaysSubs.delete(sub);
|
|
65
|
+
|
|
66
|
+
// Only LIVE reads count toward "this subscriber has provable deps" — an
|
|
67
|
+
// eval that read no present state key keeps the walk's re-evaluate-on-any-
|
|
68
|
+
// flush contract even when absent identifiers were seen (they may be plain
|
|
69
|
+
// globals forever). Scope-only evals stay out of the always-bucket: the
|
|
70
|
+
// row's iteration is subscribed and owns the row.
|
|
71
|
+
if (reads.size === 0 && !sawScope) alwaysSubs.add(sub);
|
|
72
|
+
|
|
73
|
+
// Absent identifiers still index: if the key is ever CREATED, that flush
|
|
74
|
+
// must select this subscriber — the walk got this by token-matching against
|
|
75
|
+
// the NEW state's key set every cycle. Same for delete-then-recreate: the
|
|
76
|
+
// post-delete re-eval keeps the key as an absent dep.
|
|
77
|
+
const deps = absent.size === 0 ? reads : new Set([...reads, ...absent]);
|
|
78
|
+
for (const key of deps) {
|
|
79
|
+
let bucket = keyIndex.get(key);
|
|
80
|
+
if (!bucket) keyIndex.set(key, (bucket = new Set()));
|
|
81
|
+
bucket.add(sub);
|
|
82
|
+
}
|
|
83
|
+
subDeps.set(sub, deps);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// The single recording entry point — called from state.js's get trap,
|
|
87
|
+
// evalInScope's parameter resolution, dollarFor's snapshot wrapper, and
|
|
88
|
+
// createScopedState's global fallthrough. One null check when inactive.
|
|
89
|
+
export const recordRead = (key) => {
|
|
90
|
+
if (active === null || typeof key !== 'string') return;
|
|
91
|
+
if (active.skipKeys !== null && active.skipKeys.has(key)) {
|
|
92
|
+
active.sawScope = true;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
active.reads.add(key);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// A read that MISSED the state (identifier absent at evaluation time —
|
|
99
|
+
// evalInScope's global fallback, dollarFor's non-key miss). Indexed so the
|
|
100
|
+
// key's later creation dispatches this subscriber, but not counted as a live
|
|
101
|
+
// dep — see commit.
|
|
102
|
+
export const recordAbsentRead = (key) => {
|
|
103
|
+
if (active === null || typeof key !== 'string') return;
|
|
104
|
+
if (active.skipKeys !== null && active.skipKeys.has(key)) {
|
|
105
|
+
active.sawScope = true;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
active.absent.add(key);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Union of subscribers for a set of changed root keys, plus the conservative
|
|
112
|
+
// always-bucket. Callers filter liveness (anchor.isConnected) at use time.
|
|
113
|
+
export const subscribersOf = (keys) => {
|
|
114
|
+
const out = new Set(alwaysSubs);
|
|
115
|
+
for (const key of keys) {
|
|
116
|
+
const bucket = keyIndex.get(key);
|
|
117
|
+
if (bucket) for (const sub of bucket) out.add(sub);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const depsOf = (sub) => subDeps.get(sub);
|
|
123
|
+
|
|
124
|
+
// Force a subscriber into the conservative always-bucket. Batch/compiled
|
|
125
|
+
// iterations use this: their row HTML is produced by a compiled function
|
|
126
|
+
// closing over every state key, so "re-evaluate on any change" is exactly
|
|
127
|
+
// the walk's contract for them (the identical-HTML guard absorbs no-ops).
|
|
128
|
+
export const markAlways = (sub) => {
|
|
129
|
+
const prev = subDeps.get(sub);
|
|
130
|
+
if (prev) dropFromBuckets(sub, prev);
|
|
131
|
+
alwaysSubs.add(sub);
|
|
132
|
+
subDeps.set(sub, new Set());
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const unsubscribe = (sub) => {
|
|
136
|
+
const prev = subDeps.get(sub);
|
|
137
|
+
if (prev) dropFromBuckets(sub, prev);
|
|
138
|
+
alwaysSubs.delete(sub);
|
|
139
|
+
subDeps.delete(sub);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Drop every subscriber whose anchor node has left the document. Called on
|
|
143
|
+
// teardown batches (mutation removals, iteration row release, branch
|
|
144
|
+
// unmount) — O(subscribers), same cost class as the manifest sweep.
|
|
145
|
+
export const pruneDisconnected = () => {
|
|
146
|
+
for (const sub of subDeps.keys()) {
|
|
147
|
+
if (sub.anchor && !sub.anchor.isConnected) unsubscribe(sub);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Called at the top of a state flush with the changed root keys. Returns the
|
|
152
|
+
// dirty set — every live subscriber of those keys plus the conservative
|
|
153
|
+
// always-bucket. Dead subscribers found here unsubscribe on the spot (the
|
|
154
|
+
// lazy self-prune).
|
|
155
|
+
export const beginFlush = (changedKeys) => {
|
|
156
|
+
const dirty = new Set();
|
|
157
|
+
for (const sub of subscribersOf(changedKeys)) {
|
|
158
|
+
if (sub.anchor && sub.anchor.isConnected === false) {
|
|
159
|
+
unsubscribe(sub);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
dirty.add(sub);
|
|
163
|
+
}
|
|
164
|
+
return dirty;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export const resetTracking = () => {
|
|
168
|
+
windows = [];
|
|
169
|
+
active = null;
|
|
170
|
+
keyIndex.clear();
|
|
171
|
+
alwaysSubs.clear();
|
|
172
|
+
subDeps.clear();
|
|
173
|
+
};
|