@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.
Files changed (57) hide show
  1. package/README.md +14 -4
  2. package/boot.js +4 -4
  3. package/component.js +27 -29
  4. package/hot-module-refresh.js +4 -4
  5. package/index.js +10 -15
  6. package/llms.txt +8 -6
  7. package/package.json +19 -14
  8. package/runtime/affected.js +159 -36
  9. package/runtime/cleanup.js +45 -1
  10. package/runtime/component.js +312 -99
  11. package/runtime/conditionals.js +111 -14
  12. package/runtime/debug.js +24 -0
  13. package/runtime/dispatch.js +172 -0
  14. package/runtime/hydrate.js +251 -111
  15. package/runtime/index.js +180 -71
  16. package/runtime/iterate.js +125 -50
  17. package/runtime/iteration-utils.js +59 -8
  18. package/runtime/manifest.js +77 -2
  19. package/runtime/parse.js +69 -5
  20. package/runtime/pre-compiled-iterations.js +19 -6
  21. package/runtime/pre-compiled-manifest.js +13 -4
  22. package/runtime/staging.js +153 -0
  23. package/runtime/state.js +31 -0
  24. package/runtime/tracking.js +173 -0
  25. package/runtime/utils.js +155 -78
  26. package/spa.js +77 -14
  27. package/vibe.css +8 -4
  28. package/CHANGELOG.md +0 -1196
  29. package/ROADMAP.md +0 -397
  30. package/compiler/bin/vibe-compile.js +0 -121
  31. package/compiler/native/.gitkeep +0 -0
  32. package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
  33. package/compiler/native/vibe-compiler-linux-x64 +0 -0
  34. package/compiler/src/Cargo.lock +0 -2023
  35. package/compiler/src/Cargo.toml +0 -38
  36. package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
  37. package/compiler/src/compiler/binding_case.rs +0 -88
  38. package/compiler/src/compiler/compile.rs +0 -2880
  39. package/compiler/src/compiler/component_tagger.rs +0 -469
  40. package/compiler/src/compiler/iteration_optimizer.rs +0 -455
  41. package/compiler/src/compiler/js_analyzer.rs +0 -715
  42. package/compiler/src/compiler/manifest_builder.rs +0 -693
  43. package/compiler/src/compiler/mod.rs +0 -16
  44. package/compiler/src/compiler/name_binding_protect.rs +0 -207
  45. package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
  46. package/compiler/src/compiler/spa.rs +0 -477
  47. package/compiler/src/compiler/state_extractor.rs +0 -263
  48. package/compiler/src/compiler/value_stamper.rs +0 -921
  49. package/compiler/src/compiler/watcher.rs +0 -1278
  50. package/compiler/src/config.rs +0 -279
  51. package/compiler/src/main.rs +0 -358
  52. package/compiler/src/parser/element.rs +0 -96
  53. package/compiler/src/parser/html.rs +0 -1004
  54. package/compiler/src/parser/mod.rs +0 -8
  55. package/runtime/pre-compiled-manifest.test.mjs +0 -58
  56. package/runtime/scope.js +0 -50
  57. package/test-results/.last-run.json +0 -4
package/runtime/utils.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { THIS_PROP_REGEX } from './constants.js';
2
+ import { recordRead, recordAbsentRead } from './tracking.js';
3
+ import { reportEvalError } from './debug.js';
2
4
 
3
5
  // Fast incrementing counter instead of expensive random hash
4
6
  let hashCounter = 0;
@@ -25,6 +27,29 @@ export const rememberScopedKeys = (state, keys, overlay) => {
25
27
  export const ownKeysOf = (state) => scopedKeys.get(state) || Object.keys(state);
26
28
  export const scopedOverlayOf = (state) => scopedOverlay.get(state);
27
29
 
30
+ // Register just the overlay for a merged PLAIN row snapshot (affected's
31
+ // iteration descent builds those without going through createScopedState).
32
+ // Tracking uses it to tell alias reads from global reads.
33
+ export const rememberOverlay = (state, overlay) => {
34
+ if (overlay) scopedOverlay.set(state, overlay);
35
+ return state;
36
+ };
37
+
38
+ // The overlay's key set, cached per state object — the skipKeys a tracking
39
+ // window needs so loop-alias reads don't subscribe.
40
+ const overlayKeySets = new WeakMap();
41
+ export const overlayKeysOf = (state) => {
42
+ if (state === null || typeof state !== 'object') return null;
43
+ const overlay = scopedOverlay.get(state);
44
+ if (!overlay) return null;
45
+ let set = overlayKeySets.get(state);
46
+ if (!set) {
47
+ set = new Set(Object.keys(overlay));
48
+ overlayKeySets.set(state, set);
49
+ }
50
+ return set;
51
+ };
52
+
28
53
  // The live root reactive proxy, wired once at boot by index.js. Used ONLY to
29
54
  // reach the root's non-enumerable helper methods (`$.unsafe`, `$.on`, …) — NOT
30
55
  // for state reads.
@@ -61,7 +86,12 @@ const dollarFor = (state) => {
61
86
  let wrapped = dollarCache.get(state);
62
87
  if (!wrapped) {
63
88
  wrapped = new Proxy(state, {
64
- get: (t, k) => (k in t ? t[k] : rootHelper(k) ? rootProxy[k] : undefined),
89
+ // A `$.key` read on a plain snapshot never touches the live proxy, so
90
+ // the tracking trap can't see it — record it here. The helper fallback
91
+ // isn't a state read; a genuine miss records absent-tier so the key's
92
+ // later creation dispatches this subscriber.
93
+ get: (t, k) =>
94
+ k in t ? (recordRead(k), t[k]) : rootHelper(k) ? rootProxy[k] : (recordAbsentRead(k), undefined),
65
95
  has: (t, k) => k in t || rootHelper(k),
66
96
  });
67
97
  dollarCache.set(state, wrapped);
@@ -69,9 +99,81 @@ const dollarFor = (state) => {
69
99
  return wrapped;
70
100
  };
71
101
 
72
- // Function compilation cache: avoids creating new Function() for repeated expressions
73
- // Key: normalized expression + '\0' + state keys joined by '\0'
102
+ // Function compilation cache: avoids creating new Function() for repeated
103
+ // expressions. Keyed by the CANONICALIZED expression alone params derive
104
+ // from the expression's own free identifiers (state-first, live global
105
+ // fallback at call time), and per-mount component ids (`_cN`) canonicalize
106
+ // into parameters — so the cache is bounded by the app's distinct template
107
+ // expressions, not by state shape or mount count. The previous key included
108
+ // the full state-key signature: every SPA navigation mints fresh `_cN` keys,
109
+ // so each navigation recompiled every expression and left the dead entries
110
+ // cached forever (~1,300 per navigation cycle in the game).
74
111
  const fnCache = new Map();
112
+ export const evalCacheSize = () => fnCache.size;
113
+
114
+ // Canonicalize + compile one normalized expression. One regex pass: skips
115
+ // string literals and object-literal keys, folds `$['_cN']` and bare `_cN`
116
+ // references into `$[__vibeCidK]` parameters, and collects every remaining
117
+ // free identifier (minus locals declared by arrow params, reserved words,
118
+ // and known globals) as a parameter of the compiled function.
119
+ const compileExpression = (normalized) => {
120
+ // Arrow-function parameters are locally bound — never parameters of ours.
121
+ const locals = new Set();
122
+ normalized.replace(/\(([^()]*)\)\s*=>/g, (_, paramList) => {
123
+ for (const p of paramList.split(',')) {
124
+ const name = p.trim().split('=')[0].trim();
125
+ if (/^[a-zA-Z_$][\w$]*$/.test(name)) locals.add(name);
126
+ }
127
+ return '';
128
+ });
129
+ const singleParamRe = /(?:^|[^\w$.])([a-zA-Z_$][\w$]*)\s*=>/g;
130
+ let paramMatch;
131
+ while ((paramMatch = singleParamRe.exec(normalized)) !== null) {
132
+ locals.add(paramMatch[1]);
133
+ }
134
+
135
+ const params = [];
136
+ const paramSet = new Set();
137
+ const cids = [];
138
+ const cidParams = new Map();
139
+ const cidParamFor = (id) => {
140
+ let p = cidParams.get(id);
141
+ if (!p) {
142
+ p = `__vibeCid${cids.length}`;
143
+ cidParams.set(id, p);
144
+ cids.push(id);
145
+ }
146
+ return p;
147
+ };
148
+
149
+ const canonical = normalized.replace(
150
+ /('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|\$\['(_c\d+)'\]|((?<=[{,]\s*)[a-zA-Z_$][\w$]*(?=\s*:))|(?<![.\w$'"])[a-zA-Z_$][\w$]*/g,
151
+ (match, strLit, bracketCid, objKey) => {
152
+ if (strLit !== undefined || objKey !== undefined) return match;
153
+ if (bracketCid !== undefined) return `$[${cidParamFor(bracketCid)}]`;
154
+ if (/^_c\d+$/.test(match)) return `$[${cidParamFor(match)}]`;
155
+ if (match === '$' || locals.has(match) || EVAL_IDENT_EXCLUDE.has(match)) return match;
156
+ if (!paramSet.has(match)) {
157
+ paramSet.add(match);
158
+ params.push(match);
159
+ }
160
+ return match;
161
+ },
162
+ );
163
+
164
+ let entry = fnCache.get(canonical);
165
+ if (!entry) {
166
+ entry = {
167
+ fn: new Function(...params, ...cidParams.values(), '$', `'use strict'; return (${canonical})`),
168
+ params,
169
+ };
170
+ fnCache.set(canonical, entry);
171
+ }
172
+ // Params/cids are derived deterministically from the expression text, so a
173
+ // cache hit from a DIFFERENT original spelling (another mount's `_cN`)
174
+ // reuses the compiled fn with THIS call's ids.
175
+ return { fn: entry.fn, params: entry.params, cids };
176
+ };
75
177
 
76
178
  // Built-ins and reserved words that must NEVER be pre-declared as `var`
77
179
  // inside a compiled expression — they're either real globals we want to
@@ -79,12 +181,14 @@ const fnCache = new Map();
79
181
  const EVAL_IDENT_EXCLUDE = new Set([
80
182
  // Primitive literals / special identifiers
81
183
  'true', 'false', 'null', 'undefined', 'NaN', 'Infinity', 'this',
82
- // JS reserved words (shadowing any of these is a SyntaxError)
184
+ // JS reserved words (shadowing any of these is a SyntaxError) — plus
185
+ // `arguments`/`eval`, which are legal identifiers but illegal PARAMETER
186
+ // names in strict mode.
83
187
  'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger',
84
188
  'default', 'delete', 'do', 'else', 'export', 'extends', 'finally',
85
189
  'for', 'function', 'if', 'import', 'in', 'instanceof', 'let', 'new',
86
190
  'of', 'return', 'super', 'switch', 'throw', 'try', 'typeof', 'var',
87
- 'void', 'while', 'with', 'yield', 'async', 'await',
191
+ 'void', 'while', 'with', 'yield', 'async', 'await', 'arguments', 'eval',
88
192
  // Common runtime globals authors reach for
89
193
  'Math', 'Array', 'Object', 'String', 'Number', 'Boolean', 'Date',
90
194
  'JSON', 'RegExp', 'Error', 'Map', 'Set', 'WeakMap', 'WeakSet',
@@ -98,6 +202,23 @@ const EVAL_IDENT_EXCLUDE = new Set([
98
202
  // a quote (inside a string literal).
99
203
  const FREE_IDENT_REGEX = /(?<![.\w$'"])[a-zA-Z_$][\w$]*/g;
100
204
 
205
+ // Resolve a global identifier through the SCOPE CHAIN, not the globalThis
206
+ // object — top-level let/const/class helpers in classic scripts live in the
207
+ // global declarative record, which `globalThis[p]` can't see. A cached
208
+ // per-identifier probe compiled at global scope reaches both records with
209
+ // the typeof-guard the compiled expressions themselves used pre-3.0.0.
210
+ // `p` is always a regex-validated non-reserved identifier (compileExpression
211
+ // filters keywords via EVAL_IDENT_EXCLUDE), so the probe source is inert.
212
+ const globalProbeCache = new Map();
213
+ const readGlobal = (p) => {
214
+ let probe = globalProbeCache.get(p);
215
+ if (!probe) {
216
+ probe = new Function(`return typeof ${p} === 'undefined' ? undefined : ${p};`);
217
+ globalProbeCache.set(p, probe);
218
+ }
219
+ return probe();
220
+ };
221
+
101
222
  // Evaluate expression in the context of state
102
223
  // Handles @[this.property] for component state and @[property] for global state
103
224
  export const evalInScope = (expr, state, element = null) => {
@@ -114,79 +235,34 @@ export const evalInScope = (expr, state, element = null) => {
114
235
  }
115
236
  }
116
237
 
117
- // Get state keys once. ownKeysOf reads the scoped-state proxy's precomputed
118
- // key list instead of Object.keys() — the latter fires getOwnPropertyDescriptor
119
- // for every key, a per-eval combat hot spot.
120
- const stateKeys = ownKeysOf(state);
121
- const keyCount = stateKeys.length;
122
-
123
- // Cache lookup: expression + key signature compiled function
124
- // All iteration instances share the same state shape, so this hits cache 999/1000 times
125
- const cacheKey = normalized + '\0' + stateKeys.join('\0');
126
- let fn = fnCache.get(cacheKey);
127
- if (!fn) {
128
- const allKeys = new Array(keyCount + 1);
129
- for (let i = 0; i < keyCount; i++) allKeys[i] = stateKeys[i];
130
- allKeys[keyCount] = '$';
131
-
132
- // Rewrite free identifiers that are neither state keys nor known
133
- // globals/reserved words so they resolve to `undefined` instead of
134
- // throwing ReferenceError. `typeof x` is the only operator that can
135
- // probe a name without reading it, so we guard each such reference:
136
- //
137
- // missingProp → (typeof missingProp==='undefined'?void 0:missingProp)
138
- //
139
- // This keeps author intuition working for absent state (`!undef` is
140
- // `true`, `undef?.x` is `undefined`) while leaving real globals
141
- // functions the app exposes on `window` reachable via normal
142
- // identifier lookup. The previous approach (`var undef;` hoist) also
143
- // shadowed those globals to `undefined`, so a template call like
144
- // `getLevelByExperience(exp)` threw TypeError and silently rendered
145
- // "undefined".
146
- const known = new Set(allKeys);
147
- for (const ex of EVAL_IDENT_EXCLUDE) known.add(ex);
148
-
149
- // Arrow function parameters are bound locally — rewriting them breaks
150
- // the arrow's parameter list syntax (e.g. `f =>` must stay a bare ident).
151
- // Collect both `(a, b) =>` and `x =>` forms, strip defaults, and treat
152
- // them as known so the main pass leaves them untouched.
153
- normalized.replace(/\(([^()]*)\)\s*=>/g, (_, paramList) => {
154
- for (const p of paramList.split(',')) {
155
- const name = p.trim().split('=')[0].trim();
156
- if (/^[a-zA-Z_$][\w$]*$/.test(name)) known.add(name);
157
- }
158
- return '';
159
- });
160
- const singleParamRe = /(?:^|[^\w$.])([a-zA-Z_$][\w$]*)\s*=>/g;
161
- let paramMatch;
162
- while ((paramMatch = singleParamRe.exec(normalized)) !== null) {
163
- known.add(paramMatch[1]);
164
- }
165
-
166
- // Match string literals first so hyphens inside them (e.g. 'the-arena')
167
- // don't cause the trailing word to be rewritten as a free identifier.
168
- // Also capture object-literal keys (`{foo: ...}` / `, foo: ...`) as a
169
- // second alternative — they're property names, not references, so
170
- // rewriting them produces invalid JS (computed key without brackets).
171
- const source = normalized.replace(
172
- /('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|((?<=[{,]\s*)[a-zA-Z_$][\w$]*(?=\s*:))|(?<![.\w$'"])[a-zA-Z_$][\w$]*/g,
173
- (match, strLit, objKey) => {
174
- if (strLit !== undefined) return match;
175
- if (objKey !== undefined) return match;
176
- return known.has(match) ? match : `(typeof ${match}==='undefined'?void 0:${match})`;
177
- },
178
- );
179
- fn = new Function(...allKeys, `'use strict'; return (${source})`);
180
- fnCache.set(cacheKey, fn);
238
+ const { fn, params, cids } = compileExpression(normalized);
239
+
240
+ // Build values matching the compiled parameter order: the expression's
241
+ // free identifiers, the canonicalized component-id strings, and `$` via
242
+ // dollarFor the same state for reads (so old/new diffing works), with
243
+ // helper-method fallback to the live root. An identifier resolves state-
244
+ // first by ENUMERABLE OWN KEY presence ownKeysOf, the exact reach the
245
+ // old key-signature params had: snapshots and scoped states expose their
246
+ // keys there (a key holding undefined still shadows a global), while the
247
+ // root's non-enumerable helpers ($.on, $.unsafe) stay invisible — a state
248
+ // key named `on` must never resolve the hook method. `in` would see
249
+ // those helpers and the prototype chain; ownKeysOf is computed per call,
250
+ // so a live root proxy mutated between evals stays accurate. Everything
251
+ // else resolves through the scope-chain probe (readGlobal) and records
252
+ // an absent-tier read, so the key's later creation re-renders this
253
+ // binding instead of being silently missed.
254
+ const stateObj = state == null ? {} : state;
255
+ const values = new Array(params.length + cids.length + 1);
256
+ const keys = params.length ? ownKeysOf(stateObj) : null;
257
+ let v = 0;
258
+ for (let i = 0; i < params.length; i++) {
259
+ const p = params[i];
260
+ // State-first resolution IS a root-key read record it for the open
261
+ // tracking window (snapshot evals never reach the proxy trap).
262
+ values[v++] = keys.includes(p) ? (recordRead(p), stateObj[p]) : (recordAbsentRead(p), readGlobal(p));
181
263
  }
182
-
183
- // Build values array matching the cached function's parameter order. Bare
184
- // identifiers resolve from the passed (possibly scoped) state; the `$`
185
- // identifier resolves via dollarFor — the same state for reads (so old/new
186
- // diffing works), with helper-method fallback to the live root.
187
- const values = new Array(keyCount + 1);
188
- for (let i = 0; i < keyCount; i++) values[i] = state[stateKeys[i]];
189
- values[keyCount] = dollarFor(state);
264
+ for (let i = 0; i < cids.length; i++) values[v++] = cids[i];
265
+ values[v] = dollarFor(stateObj);
190
266
 
191
267
  const result = fn(...values);
192
268
 
@@ -209,6 +285,7 @@ export const evalInScope = (expr, state, element = null) => {
209
285
 
210
286
  return result;
211
287
  } catch (e) {
288
+ reportEvalError(expr, element, e);
212
289
  return undefined;
213
290
  }
214
291
  };
@@ -236,7 +313,7 @@ export const resolveCaseInsensitivePath = (state, path) => {
236
313
  // Resolve the root against the SAME scope evalInScope reaches, in its order:
237
314
  // the diff state (including scoped loop aliases — see ownKeysOf below), then
238
315
  // globals. A name-binding inside an iteration reads its props from a global
239
- // stash — `window.__vibeiterprops._p0.statusKey` — so a state-only walk can
316
+ // stash — `window.__vibe.iterProps._p0.statusKey` — so a state-only walk can
240
317
  // never reach it, and the camelCase leaf the parser lowercased stays lost
241
318
  // (the status-chip gray-icon parity bug: resolved in compiled, not runtime).
242
319
  const root = segments[0];
package/spa.js CHANGED
@@ -5,11 +5,13 @@
5
5
  // name }, which a reactive component src (<component src="@[page.src]">)
6
6
  // turns into a route outlet.
7
7
  //
8
- // SOFT DEPENDENCY: nothing in Vibe's runtime imports this, it imports nothing
9
- // from Vibe, and deleting it leaves a working framework. The default
8
+ // SOFT DEPENDENCY: nothing in Vibe's runtime imports this, and deleting it
9
+ // leaves a working framework. Its one import is the standalone template cache
10
+ // (itself dependency-free), so navigation prefetches ride the same in-flight
11
+ // promise the outlet's mount consumes — one request per fragment. The default
10
12
  // onNavigate writes to the runtime global window.$ at call time; pass a
11
- // custom onNavigate and the module is a pure router (parse/claim/history)
12
- // with no Vibe in sight.
13
+ // custom onNavigate and the module stays a pure router (parse/claim/history)
14
+ // over that cache.
13
15
  //
14
16
  // Route grammar (shared with the compiler's route table and route scanners):
15
17
  // /static/path literal segments
@@ -19,6 +21,19 @@
19
21
  // only, never claims a click
20
22
  // Tables are pre-sorted most-specific-first; first match wins.
21
23
 
24
+ import { fetchComponentTemplate } from './runtime/component-cache.js';
25
+
26
+ // Captured params arrive percent-encoded from the URL bar; hand authors the
27
+ // decoded value. A malformed escape (user-typed %zz) keeps the raw segment —
28
+ // URLs are external input, not authored code.
29
+ const decode = (segment) => {
30
+ try {
31
+ return decodeURIComponent(segment);
32
+ } catch {
33
+ return segment;
34
+ }
35
+ };
36
+
22
37
  // Match one pathname against one route template. Returns the params object
23
38
  // (possibly empty) on match, null otherwise. Empty segments are dropped, so
24
39
  // trailing slashes resolve to the same route.
@@ -29,11 +44,11 @@ const matchRoute = (path, route) => {
29
44
  for (let i = 0; i < routeSegments.length; i++) {
30
45
  const segment = routeSegments[i];
31
46
  if (segment.startsWith(':') && segment.endsWith('*')) {
32
- params[segment.slice(1, -1)] = pathSegments.slice(i).join('/');
47
+ params[segment.slice(1, -1)] = pathSegments.slice(i).map(decode).join('/');
33
48
  return params;
34
49
  }
35
50
  if (pathSegments[i] === undefined) return null;
36
- if (segment.startsWith(':')) params[segment.slice(1)] = pathSegments[i];
51
+ if (segment.startsWith(':')) params[segment.slice(1)] = decode(pathSegments[i]);
37
52
  else if (segment !== pathSegments[i]) return null;
38
53
  }
39
54
  return pathSegments.length === routeSegments.length ? params : null;
@@ -70,11 +85,21 @@ export const resolve = (location, routes) => {
70
85
  };
71
86
  }
72
87
  const fallback = routes.find((entry) => entry.route === '*');
88
+ // 'not-found', not '*': the slug feeds name bindings (<page @[page.name]>)
89
+ // and setAttribute('*') throws — the fallback must be attribute-safe.
73
90
  return fallback
74
- ? { path, route: '*', params: {}, src: fallback.src, name: '*', title: fallback.title }
91
+ ? { path, route: '*', params: {}, src: fallback.src, name: 'not-found', title: fallback.title }
75
92
  : null;
76
93
  };
77
94
 
95
+ // Programmatic navigation without holding the setupSpa return value: app code
96
+ // imports this and calls it like a link click. With no router active (MPA
97
+ // output, or before setupSpa runs) it falls back to a native load, so the same
98
+ // call site works in both output modes.
99
+ let activeRouter = null;
100
+ export const navigate = (path) =>
101
+ activeRouter ? activeRouter.navigate(path) : location.assign(new URL(path, location.origin));
102
+
78
103
  // Wire the router: one document-level click listener + popstate. Claiming
79
104
  // rule: same-origin, unmodified, untargeted clicks whose pathname matches a
80
105
  // REAL route — '*' never claims, so unrouted paths navigate natively (which
@@ -93,10 +118,40 @@ export const setupSpa = ({ routes, onNavigate }) => {
93
118
  return resolved && resolved.route !== '*' ? resolved : null;
94
119
  };
95
120
 
96
- const push = (url, resolved) => {
121
+ // Two-phase commit: the URL flips at interaction time, but route state —
122
+ // and with it every binding the outgoing page still renders — flips only
123
+ // once the incoming fragment's template is in hand. The old view never
124
+ // re-renders against the new route, and the prefetch IS the mount's fetch
125
+ // (same cache entry), so a navigation still costs one request. A failed
126
+ // prefetch resolves anyway: the flip proceeds and the mount path owns the
127
+ // error, exactly as before. The token makes rapid navigations last-wins —
128
+ // a superseded prefetch never applies a stale flip.
129
+ let navigationToken = 0;
130
+ const commit = (resolved, { scroll, hash }) => {
131
+ const token = ++navigationToken;
132
+ const prefetch = resolved.src
133
+ ? fetchComponentTemplate(resolved.src).catch(() => {})
134
+ : Promise.resolve();
135
+ prefetch.then(() => {
136
+ if (token !== navigationToken) return;
137
+ apply(resolved);
138
+ // A cross-page hash link scrolls to its anchor once the fragment's
139
+ // mount settles — late ready fires exactly then. Hashless navigations
140
+ // start at the top. getElementById + decode, not querySelector: a URL
141
+ // fragment is an element id, not a CSS selector — digit-leading ids
142
+ // are valid HTML but invalid selectors, and fragments arrive
143
+ // percent-encoded.
144
+ if (hash)
145
+ window.$?.on?.('ready', () =>
146
+ document.getElementById(decodeURIComponent(hash.slice(1)))?.scrollIntoView(),
147
+ );
148
+ else if (scroll) scrollTo(0, 0);
149
+ });
150
+ };
151
+
152
+ const push = (url, resolved, hash) => {
97
153
  history.pushState({}, '', url);
98
- apply(resolved);
99
- scrollTo(0, 0);
154
+ commit(resolved, { scroll: true, hash });
100
155
  };
101
156
 
102
157
  const onClick = (event) => {
@@ -110,15 +165,20 @@ export const setupSpa = ({ routes, onNavigate }) => {
110
165
  const resolved = claim(anchor);
111
166
  if (!resolved) return;
112
167
  event.preventDefault();
113
- push(anchor.href, resolved);
168
+ // Same-URL click: claimed (no reload), but no duplicate history entry —
169
+ // Back must keep meaning "the previous page". The WHOLE url: a link to
170
+ // the same pathname with a different query string is a real navigation.
171
+ if (anchor.href === location.href) return;
172
+ push(anchor.href, resolved, anchor.hash || null);
114
173
  };
115
174
 
116
175
  // History traversal re-resolves WITH the '*' fallback — symmetric with
117
176
  // deep-link entry, whose URL may itself be a '*' page. No scroll: the
118
- // browser restores scroll position on popstate.
177
+ // browser restores scroll position on popstate. Same two-phase commit:
178
+ // going back re-renders the old view only when the target is ready.
119
179
  const onPopstate = () => {
120
180
  const resolved = resolve(location, routes);
121
- if (resolved) apply(resolved);
181
+ if (resolved) commit(resolved, { scroll: false });
122
182
  };
123
183
 
124
184
  // Programmatic navigation, claiming like a link click: real route match →
@@ -137,7 +197,10 @@ export const setupSpa = ({ routes, onNavigate }) => {
137
197
  const dispose = () => {
138
198
  document.removeEventListener('click', onClick);
139
199
  removeEventListener('popstate', onPopstate);
200
+ if (activeRouter === api) activeRouter = null;
140
201
  };
141
202
 
142
- return { navigate, dispose };
203
+ const api = { navigate, dispose };
204
+ activeRouter = api;
205
+ return api;
143
206
  };
package/vibe.css CHANGED
@@ -1,12 +1,16 @@
1
- /* Vibe Framework - Hydration Styles
2
- * Elements with [vibe] attribute are hidden until framework removes it after hydration.
3
- * This prevents flash of unprocessed content and disables transitions during init.
4
- */
5
1
  [vibe-fouc],
6
2
  .vibe-fouc {
7
3
  visibility: hidden;
8
4
  }
9
5
 
6
+ /* A route/key remount stages the incoming wrapper as a hidden sibling of the
7
+ * outgoing page (which keeps its place, visible and styled) until the commit
8
+ * swaps them in one paint. display: none keeps the staged content out of
9
+ * layout so the two never occupy the document together. */
10
+ [vibe-staged] {
11
+ display: none;
12
+ }
13
+
10
14
  [vibe-fouc] *,
11
15
  .vibe-fouc * {
12
16
  transition: none !important;