@vesk/runtime 0.2.22 → 0.2.24

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/dist/router.js CHANGED
@@ -2,39 +2,66 @@ import { get, set, scope, set_active_block } from '@vesk/runtime/src/ripple-runt
2
2
  import { root } from '@vesk/runtime/src/ripple-blocks';
3
3
  import { createHydrateWalker, hydrateViewport, hydrateIdle, hydrateOnInteraction } from '@vesk/runtime/src/hydrate';
4
4
  import { matchRoute, flattenLayoutChain, buildTreeFromMap } from '@vesk/runtime/src/router-match';
5
- import { setIsHydrating, _state, _scrollPositions, _isPopStateNavigation, setIsPopStateNavigation, setCurrentRouter, showLoadingInContainer, handleScroll, applyHead, findLoadingComponent, findErrorComponent, findNotFoundComponent, findOfflineComponent, findNetworkComponent, getCurrentRouter, Outlet, Link, NavLink, useNavigate, useParams, usePathname, useSearchParams, useRouter, Redirect, redirect, permanentRedirect, NotFoundError, notFound, } from '@vesk/runtime/src/router-components';
5
+ import { setIsHydrating, _state, _scrollPositions, _isPopStateNavigation, setIsPopStateNavigation, setCurrentRouter, captureInitialScroll, showLoadingInContainer, handleScroll, applyHead, findLoadingComponent, findErrorComponent, findNotFoundComponent, findOfflineComponent, findNetworkComponent, getCurrentRouter, Outlet, Link, NavLink, useNavigate, useParams, usePathname, useSearchParams, useRouter, Redirect, redirect, permanentRedirect, NotFoundError, notFound, } from '@vesk/runtime/src/router-components';
6
6
  import { getNetworkState, watchNetwork } from '@vesk/runtime/src/network';
7
7
  import { loadingStart, loadingFinish, getLoadingState } from '@vesk/runtime/src/loading-indicator';
8
8
  export { Outlet, Link, NavLink, useNavigate, useParams, usePathname, useSearchParams, useRouter, Redirect, redirect, permanentRedirect, NotFoundError, notFound, ensureChunk, };
9
9
  export { matchRoute } from '@vesk/runtime/src/router-match';
10
10
  const loadedChunks = new Set();
11
11
  const failedChunks = new Map();
12
+ const inFlightChunks = new Map();
12
13
  function chunkLoadError(chunkUrl) {
13
14
  return failedChunks.get(chunkUrl);
14
15
  }
15
16
  function ensureChunk(chunkUrl) {
16
17
  if (!chunkUrl || loadedChunks.has(chunkUrl))
17
18
  return Promise.resolve();
18
- loadedChunks.add(chunkUrl);
19
+ const inFlight = inFlightChunks.get(chunkUrl);
20
+ if (inFlight)
21
+ return inFlight;
19
22
  if (typeof document === 'undefined' || typeof document.createElement !== 'function') {
20
23
  return Promise.resolve();
21
24
  }
22
- return new Promise((resolve, reject) => {
25
+ const promise = new Promise((resolve, reject) => {
23
26
  const s = document.createElement('script');
24
27
  s.src = chunkUrl;
28
+ let settled = false;
29
+ const fail = (err) => {
30
+ if (settled)
31
+ return;
32
+ settled = true;
33
+ clearTimeout(timer);
34
+ inFlightChunks.delete(chunkUrl);
35
+ failedChunks.set(chunkUrl, err);
36
+ reject(err);
37
+ };
25
38
  s.onload = () => {
39
+ if (settled)
40
+ return;
41
+ settled = true;
42
+ clearTimeout(timer);
43
+ loadedChunks.add(chunkUrl);
44
+ inFlightChunks.delete(chunkUrl);
26
45
  failedChunks.delete(chunkUrl);
27
46
  resolve();
28
47
  };
29
48
  s.onerror = () => {
30
- loadedChunks.delete(chunkUrl);
31
- const err = new Error(`Failed to load chunk: ${chunkUrl}`);
32
- failedChunks.set(chunkUrl, err);
33
- reject(err);
49
+ fail(new Error(`Failed to load chunk: ${chunkUrl}`));
34
50
  };
51
+ const timer = setTimeout(() => {
52
+ fail(new Error(`Timed out loading chunk: ${chunkUrl}`));
53
+ }, CHUNK_LOAD_TIMEOUT_MS);
35
54
  document.head.appendChild(s);
36
55
  });
56
+ inFlightChunks.set(chunkUrl, promise);
57
+ return promise;
37
58
  }
59
+ // A chunk script that never fires load/error (dev server slow to respond,
60
+ // throttled connection) must not strand the navigation forever — the URL is
61
+ // already updated before chunks load, so a silent stall would leave the new
62
+ // URL showing the old page. After this timeout the load rejects and the
63
+ // navigation renders its error fallback instead.
64
+ const CHUNK_LOAD_TIMEOUT_MS = 10000;
38
65
  /** Loads pending chunks without letting a single failed chunk abort the flow. */
39
66
  function loadChunksQuietly(urls) {
40
67
  navDebug('loadChunks start', urls);
@@ -52,15 +79,134 @@ function navDebug(...parts) {
52
79
  }
53
80
  function hasPendingChunks(nodes) {
54
81
  const urls = [];
55
- function walk(n) {
56
- if (n._chunk && !loadedChunks.has(n._chunk))
57
- urls.push(n._chunk);
58
- if (n.children)
59
- n.children.forEach(walk);
82
+ const seen = new Set();
83
+ // Only the chunks backing this navigation's match chain (layouts + page
84
+ // node) are needed. Recursing into a node's children would pull in every
85
+ // sibling route's chunk — e.g. one nav from the root layout would inject
86
+ // all 27 page bundles — stalling the navigation while unrelated chunks
87
+ // load and marking routes that never loaded as "pending".
88
+ for (const n of nodes) {
89
+ if (n._chunk && !seen.has(n._chunk)) {
90
+ seen.add(n._chunk);
91
+ if (!loadedChunks.has(n._chunk)) {
92
+ urls.push(n._chunk);
93
+ }
94
+ }
60
95
  }
61
- nodes.forEach(walk);
62
96
  return urls;
63
97
  }
98
+ /**
99
+ * Delegated route-chunk preloading for code-split routes.
100
+ *
101
+ * Uses bubbling events (`pointerover`/`focusin`) instead of `mouseenter` —
102
+ * `mouseenter` does NOT bubble, so a listener on `document` only fires once
103
+ * when the pointer enters the page and never for a specific link. That made
104
+ * the historic hover-prefetch dead code: the first click on a route always
105
+ * paid the chunk download (the paint gate) and only later visits were warm.
106
+ *
107
+ * Warming loads route chunks early so the chunk roundtrip never blocks first
108
+ * paint. Route DATA is deliberately left untouched here — it is fetched at
109
+ * navigation time exactly as before (once per visit), so SSR/initial-load and
110
+ * data-freshness semantics are unchanged.
111
+ *
112
+ * Also warms links already in the DOM: as a link scrolls near the viewport
113
+ * (IntersectionObserver, 300px margin) its chunk is preloaded for the
114
+ * first click. Falls back to warming the first 16 links at idle when
115
+ * IntersectionObserver is unavailable.
116
+ */
117
+ function installLinkPrefetch(warmChunks) {
118
+ if (typeof document === 'undefined' || typeof document.addEventListener !== 'function')
119
+ return;
120
+ const recent = new Map();
121
+ const over = (e) => {
122
+ const target = e.target;
123
+ if (!target || target.nodeType !== 1)
124
+ return;
125
+ const link = target.closest ? target.closest('a[href]') : null;
126
+ if (!link)
127
+ return;
128
+ const href = link.getAttribute('href');
129
+ if (!href)
130
+ return;
131
+ const now = Date.now();
132
+ if (now - (recent.get(href) || 0) < 500)
133
+ return;
134
+ recent.set(href, now);
135
+ warmChunks(href);
136
+ };
137
+ document.addEventListener('pointerover', over, { passive: true });
138
+ document.addEventListener('focusin', over, { passive: true });
139
+ const schedule = (cb) => {
140
+ const ric = typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function'
141
+ ? window.requestIdleCallback.bind(window)
142
+ : (fn) => setTimeout(fn, 250);
143
+ ric(() => { try {
144
+ cb();
145
+ }
146
+ catch { /* best-effort warmup */ } }, { timeout: 2000 });
147
+ };
148
+ schedule(() => {
149
+ if (typeof document.querySelectorAll !== 'function')
150
+ return;
151
+ const anchors = Array.from(document.querySelectorAll('a[href]'));
152
+ if (anchors.length === 0)
153
+ return;
154
+ const seen = new Set();
155
+ let budget = 16;
156
+ const warm = (a) => {
157
+ const href = a.getAttribute('href');
158
+ if (!href || seen.has(href) || budget-- <= 0)
159
+ return;
160
+ seen.add(href);
161
+ warmChunks(href);
162
+ };
163
+ if (typeof IntersectionObserver === 'function') {
164
+ const io = new IntersectionObserver((entries) => {
165
+ for (const entry of entries) {
166
+ if (!entry.isIntersecting)
167
+ continue;
168
+ warm(entry.target);
169
+ io.unobserve(entry.target);
170
+ }
171
+ if (budget <= 0)
172
+ io.disconnect();
173
+ }, { rootMargin: '300px' });
174
+ for (const a of anchors)
175
+ io.observe(a);
176
+ }
177
+ else {
178
+ for (const a of anchors)
179
+ warm(a);
180
+ }
181
+ });
182
+ }
183
+ /**
184
+ * Warms the chunks backing a route link without touching route data — the
185
+ * data fetch stays at navigation time (once per visit), preserving SSR
186
+ * initial-load and freshness semantics.
187
+ */
188
+ function warmRouteChunks(routeTree, href) {
189
+ if (!href)
190
+ return;
191
+ let url;
192
+ try {
193
+ url = new URL(href, typeof window !== 'undefined' ? window.location.origin : 'http://localhost');
194
+ }
195
+ catch {
196
+ return;
197
+ }
198
+ const pathname = url.pathname;
199
+ if (typeof window !== 'undefined' && pathname === window.location.pathname && !url.search && !url.hash)
200
+ return;
201
+ const match = matchRoute(routeTree, pathname);
202
+ if (!match)
203
+ return;
204
+ const urls = hasPendingChunks(match.matchChain);
205
+ if (urls.length === 0)
206
+ return;
207
+ for (const u of urls)
208
+ ensureChunk(u);
209
+ }
64
210
  /**
65
211
  * Classifies a data-fetch failure as a connectivity problem rather than a
66
212
  * server problem: fetch rejects with TypeError on network failure, and
@@ -1114,6 +1260,7 @@ export function createRouter(routes, options = {}) {
1114
1260
  start() {
1115
1261
  setCurrentRouter(this);
1116
1262
  if (typeof window !== 'undefined' && 'scrollRestoration' in window.history) {
1263
+ captureInitialScroll();
1117
1264
  window.history.scrollRestoration = 'manual';
1118
1265
  }
1119
1266
  if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
@@ -1197,11 +1344,7 @@ export function createRouter(routes, options = {}) {
1197
1344
  this.navigate(href);
1198
1345
  });
1199
1346
  if (prefetch) {
1200
- document.addEventListener('mouseenter', (e) => {
1201
- const link = e.target?.nodeType === 1 ? e.target.closest('a[href]') : null;
1202
- if (link)
1203
- this.prefetch(link.getAttribute('href'));
1204
- }, { passive: true });
1347
+ installLinkPrefetch((href) => warmRouteChunks(this.routeTree, href));
1205
1348
  }
1206
1349
  const path = routeFromLocation(hashMode);
1207
1350
  if (container.children.length > 0 && !hashMode) {
@@ -1265,7 +1408,7 @@ export function createRouter(routes, options = {}) {
1265
1408
  }
1266
1409
  this._currentMatch = match;
1267
1410
  clearOfflineFlag(this, container);
1268
- handleScroll(url.pathname, opts.replace);
1411
+ handleScroll(url.pathname, opts.replace, opts.scrollBehavior);
1269
1412
  };
1270
1413
  const swapView = (fn) => {
1271
1414
  const d = document;
@@ -1498,6 +1641,7 @@ export function createFileRouter(routeTree, options = {}) {
1498
1641
  start() {
1499
1642
  setCurrentRouter(this);
1500
1643
  if (typeof window !== 'undefined' && 'scrollRestoration' in window.history) {
1644
+ captureInitialScroll();
1501
1645
  window.history.scrollRestoration = 'manual';
1502
1646
  }
1503
1647
  if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
@@ -1573,11 +1717,7 @@ export function createFileRouter(routeTree, options = {}) {
1573
1717
  router.navigate(href);
1574
1718
  });
1575
1719
  if (options.prefetch !== false) {
1576
- document.addEventListener('mouseenter', (e) => {
1577
- const link = e.target?.nodeType === 1 ? e.target.closest('a[href]') : null;
1578
- if (link)
1579
- router.prefetch(link.getAttribute('href'));
1580
- }, { passive: true });
1720
+ installLinkPrefetch((href) => warmRouteChunks(router.routeTree, href));
1581
1721
  }
1582
1722
  const path = routeFromLocation(hashMode).split('?')[0];
1583
1723
  if (container.children.length > 0 && !hashMode) {
@@ -1674,7 +1814,7 @@ export function createFileRouter(routeTree, options = {}) {
1674
1814
  }
1675
1815
  router._currentMatch = match;
1676
1816
  clearOfflineFlag(router, container);
1677
- handleScroll(url.pathname, opts.replace);
1817
+ handleScroll(url.pathname, opts.replace, opts.scrollBehavior);
1678
1818
  };
1679
1819
  const doRender = () => {
1680
1820
  navDebug('doRender', url.pathname, 'token=' + navToken);
@@ -1788,6 +1928,7 @@ export function createFileRouter(routeTree, options = {}) {
1788
1928
  router.navigate(e.url, { replace: true });
1789
1929
  return;
1790
1930
  }
1931
+ handleNavFailure(e);
1791
1932
  }
1792
1933
  }
1793
1934
  if (middlewareFns.length > 0 || loadingFn) {
@@ -1919,6 +2060,7 @@ export function buildRouteTree(definitions) {
1919
2060
  notFound: def.notFound || null,
1920
2061
  offline: def.offline ?? null,
1921
2062
  network: def.network ?? null,
2063
+ standalone: def.standalone ? true : undefined,
1922
2064
  children: (def.children || []).map(c => {
1923
2065
  const cParts = (c.path || '').split('/').filter(Boolean);
1924
2066
  return {
@@ -1931,6 +2073,7 @@ export function buildRouteTree(definitions) {
1931
2073
  loading: null,
1932
2074
  error: null,
1933
2075
  notFound: null,
2076
+ standalone: c.standalone ? true : undefined,
1934
2077
  segmentCount: cParts.length || 1,
1935
2078
  children: [],
1936
2079
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vesk/runtime",
3
- "version": "0.2.22",
3
+ "version": "0.2.24",
4
4
  "type": "module",
5
5
  "description": "Vesk runtime — reactivity, blocks, DOM operations, hydration",
6
6
  "main": "./dist/index-client.js",
@@ -25,8 +25,33 @@
25
25
  "./src/*": "./dist/*.js",
26
26
  "./package.json": "./package.json"
27
27
  },
28
+ "typesVersions": {
29
+ "*": {
30
+ "router": [
31
+ "dist/index-router.d.ts"
32
+ ],
33
+ "client": [
34
+ "dist/index-client.d.ts"
35
+ ],
36
+ "server": [
37
+ "dist/index-server.d.ts"
38
+ ],
39
+ "src/*": [
40
+ "dist/*"
41
+ ],
42
+ "router/*": [
43
+ "dist/*"
44
+ ],
45
+ "client/*": [
46
+ "dist/*"
47
+ ],
48
+ "server/*": [
49
+ "dist/*"
50
+ ]
51
+ }
52
+ },
28
53
  "dependencies": {
29
- "@vesk/compiler": "^0.2.22"
54
+ "@vesk/compiler": "^0.2.24"
30
55
  },
31
56
  "scripts": {
32
57
  "typecheck": "tsc --noEmit",