@vesk/runtime 0.2.21 → 0.2.23

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
@@ -9,32 +9,59 @@ export { Outlet, Link, NavLink, useNavigate, useParams, usePathname, useSearchPa
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
@@ -1197,11 +1343,7 @@ export function createRouter(routes, options = {}) {
1197
1343
  this.navigate(href);
1198
1344
  });
1199
1345
  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 });
1346
+ installLinkPrefetch((href) => warmRouteChunks(this.routeTree, href));
1205
1347
  }
1206
1348
  const path = routeFromLocation(hashMode);
1207
1349
  if (container.children.length > 0 && !hashMode) {
@@ -1573,11 +1715,7 @@ export function createFileRouter(routeTree, options = {}) {
1573
1715
  router.navigate(href);
1574
1716
  });
1575
1717
  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 });
1718
+ installLinkPrefetch((href) => warmRouteChunks(router.routeTree, href));
1581
1719
  }
1582
1720
  const path = routeFromLocation(hashMode).split('?')[0];
1583
1721
  if (container.children.length > 0 && !hashMode) {
@@ -1788,6 +1926,7 @@ export function createFileRouter(routeTree, options = {}) {
1788
1926
  router.navigate(e.url, { replace: true });
1789
1927
  return;
1790
1928
  }
1929
+ handleNavFailure(e);
1791
1930
  }
1792
1931
  }
1793
1932
  if (middlewareFns.length > 0 || loadingFn) {
@@ -1919,6 +2058,7 @@ export function buildRouteTree(definitions) {
1919
2058
  notFound: def.notFound || null,
1920
2059
  offline: def.offline ?? null,
1921
2060
  network: def.network ?? null,
2061
+ standalone: def.standalone ? true : undefined,
1922
2062
  children: (def.children || []).map(c => {
1923
2063
  const cParts = (c.path || '').split('/').filter(Boolean);
1924
2064
  return {
@@ -1931,6 +2071,7 @@ export function buildRouteTree(definitions) {
1931
2071
  loading: null,
1932
2072
  error: null,
1933
2073
  notFound: null,
2074
+ standalone: c.standalone ? true : undefined,
1934
2075
  segmentCount: cParts.length || 1,
1935
2076
  children: [],
1936
2077
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vesk/runtime",
3
- "version": "0.2.21",
3
+ "version": "0.2.23",
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.21"
54
+ "@vesk/compiler": "^0.2.23"
30
55
  },
31
56
  "scripts": {
32
57
  "typecheck": "tsc --noEmit",