@vesk/runtime 0.1.1 → 0.2.8

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 (42) hide show
  1. package/dist/form.js +1 -1
  2. package/dist/headless.d.ts +30 -0
  3. package/dist/headless.d.ts.map +1 -0
  4. package/dist/headless.js +39 -0
  5. package/dist/hmr-client.js +5 -2
  6. package/dist/index-client.d.ts +5 -0
  7. package/dist/index-client.d.ts.map +1 -1
  8. package/dist/index-client.js +3 -0
  9. package/dist/index-router.d.ts +16 -0
  10. package/dist/index-router.d.ts.map +1 -0
  11. package/dist/index-router.js +15 -0
  12. package/dist/index-server.d.ts +3 -0
  13. package/dist/index-server.d.ts.map +1 -1
  14. package/dist/index-server.js +2 -0
  15. package/dist/loading-indicator.d.ts +127 -0
  16. package/dist/loading-indicator.d.ts.map +1 -0
  17. package/dist/loading-indicator.js +342 -0
  18. package/dist/md.d.ts +112 -3
  19. package/dist/md.d.ts.map +1 -1
  20. package/dist/md.js +1977 -198
  21. package/dist/network.d.ts +28 -0
  22. package/dist/network.d.ts.map +1 -0
  23. package/dist/network.js +62 -0
  24. package/dist/request.d.ts.map +1 -1
  25. package/dist/request.js +22 -13
  26. package/dist/resource.d.ts +3 -1
  27. package/dist/resource.d.ts.map +1 -1
  28. package/dist/resource.js +36 -1
  29. package/dist/ripple-runtime.d.ts +11 -6
  30. package/dist/ripple-runtime.d.ts.map +1 -1
  31. package/dist/ripple-runtime.js +12 -0
  32. package/dist/router-components.d.ts +29 -3
  33. package/dist/router-components.d.ts.map +1 -1
  34. package/dist/router-components.js +73 -0
  35. package/dist/router-match.d.ts +2 -0
  36. package/dist/router-match.d.ts.map +1 -1
  37. package/dist/router-match.js +6 -3
  38. package/dist/router.d.ts +27 -0
  39. package/dist/router.d.ts.map +1 -1
  40. package/dist/router.js +770 -254
  41. package/package.json +6 -2
  42. package/dist/package.json +0 -23
package/dist/router.js CHANGED
@@ -2,7 +2,9 @@ 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, 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, showLoadingInContainer, handleScroll, applyHead, findLoadingComponent, findErrorComponent, findNotFoundComponent, findOfflineComponent, findNetworkComponent, Outlet, Link, NavLink, useNavigate, useParams, usePathname, useSearchParams, useRouter, Redirect, redirect, permanentRedirect, NotFoundError, notFound, } from '@vesk/runtime/src/router-components';
6
+ import { getNetworkState, watchNetwork } from '@vesk/runtime/src/network';
7
+ import { loadingStart, loadingFinish, getLoadingState } from '@vesk/runtime/src/loading-indicator';
6
8
  export { Outlet, Link, NavLink, useNavigate, useParams, usePathname, useSearchParams, useRouter, Redirect, redirect, permanentRedirect, NotFoundError, notFound, ensureChunk, };
7
9
  export { matchRoute } from '@vesk/runtime/src/router-match';
8
10
  const loadedChunks = new Set();
@@ -35,7 +37,18 @@ function ensureChunk(chunkUrl) {
35
37
  }
36
38
  /** Loads pending chunks without letting a single failed chunk abort the flow. */
37
39
  function loadChunksQuietly(urls) {
38
- return Promise.all(urls.map((u) => ensureChunk(u).catch(() => undefined))).then(() => undefined);
40
+ navDebug('loadChunks start', urls);
41
+ return Promise.all(urls.map((u) => ensureChunk(u).catch(() => undefined))).then(() => {
42
+ navDebug('loadChunks done', urls);
43
+ });
44
+ }
45
+ // Set window.__veskNavDebug = [] in the page to capture router breadcrumbs.
46
+ function navDebug(...parts) {
47
+ if (typeof window === 'undefined')
48
+ return;
49
+ const log = window.__veskNavDebug;
50
+ if (log)
51
+ log.push(parts.map(p => Array.isArray(p) ? p.join(',') : String(p)).join(' '));
39
52
  }
40
53
  function hasPendingChunks(nodes) {
41
54
  const urls = [];
@@ -48,6 +61,17 @@ function hasPendingChunks(nodes) {
48
61
  nodes.forEach(walk);
49
62
  return urls;
50
63
  }
64
+ /**
65
+ * Classifies a data-fetch failure as a connectivity problem rather than a
66
+ * server problem: fetch rejects with TypeError on network failure, and
67
+ * `navigator.onLine === false` confirms it even where the error shape is
68
+ * environment-specific.
69
+ */
70
+ function isConnectivityFailure(err) {
71
+ if (typeof navigator !== 'undefined' && navigator && navigator.onLine === false)
72
+ return true;
73
+ return err instanceof TypeError;
74
+ }
51
75
  const _dataPromises = new Map();
52
76
  async function fetchRouteData(path) {
53
77
  try {
@@ -83,7 +107,13 @@ async function fetchRouteData(path) {
83
107
  return null;
84
108
  return (await res.json());
85
109
  }
86
- catch {
110
+ catch (err) {
111
+ if (isConnectivityFailure(err))
112
+ return { offline: true };
113
+ // Passive signals can miss (proxies, emulation) — actively probe
114
+ // before classifying as a plain failure.
115
+ if (await looksOffline())
116
+ return { offline: true };
87
117
  return null;
88
118
  }
89
119
  }
@@ -129,7 +159,27 @@ function chunkErrorForNode(node) {
129
159
  return new Error(String(node._chunkError));
130
160
  return undefined;
131
161
  }
162
+ /**
163
+ * Active connectivity check for failure paths where passive signals
164
+ * (`navigator.onLine`) are unreliable (proxies, CDP emulation, some
165
+ * browsers lag the flag). Any HTTP response — even 404/500 — proves the
166
+ * origin is reachable; only a fetch-level rejection counts as offline.
167
+ */
168
+ async function looksOffline() {
169
+ if (typeof navigator !== 'undefined' && navigator && navigator.onLine === false)
170
+ return true;
171
+ if (typeof fetch !== 'function')
172
+ return false;
173
+ try {
174
+ await fetch('/__vesk_connectivity_' + Date.now(), { method: 'HEAD', cache: 'no-store' });
175
+ return false;
176
+ }
177
+ catch (err) {
178
+ return err instanceof TypeError;
179
+ }
180
+ }
132
181
  async function renderNotFound(router, match, container) {
182
+ clearOfflineFlag(router, container);
133
183
  const chain = match.matchChain;
134
184
  const paramValues = match.params;
135
185
  const notFoundFn = findNotFoundComponent(chain);
@@ -158,7 +208,142 @@ async function renderNotFound(router, match, container) {
158
208
  container.innerHTML = '<h1>404 — Not Found</h1>';
159
209
  router._currentMatch = match;
160
210
  }
211
+ function defaultOfflineHtml(url) {
212
+ return '<div data-vesk-offline style="all:initial;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:60vh;font-family:system-ui,-apple-system,sans-serif;color:#1f2937;text-align:center;padding:24px">' +
213
+ '<div style="font-size:40px;line-height:1;margin-bottom:16px" aria-hidden="true">📡</div>' +
214
+ '<h1 style="font-size:22px;font-weight:700;margin:0 0 8px">You\u2019re offline</h1>' +
215
+ '<p style="margin:0 0 20px;color:#6b7280;max-width:34em">Check your connection. <span style="color:#9ca3af">' + escapeOfflineUrl(url) + '</span> will load automatically once you\u2019re back online.</p>' +
216
+ '<button type="button" data-vesk-offline-retry style="all:unset;cursor:pointer;background:#2563eb;color:#fff;padding:10px 22px;border-radius:8px;font-size:14px;font-weight:600">Retry now</button>' +
217
+ '</div>';
218
+ }
219
+ function escapeOfflineUrl(url) {
220
+ return String(url).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
221
+ }
222
+ /**
223
+ * Renders the connectivity experience for a navigation that failed due to
224
+ * loss of (or degraded) network. Precedence:
225
+ * 1. nearest route `offline.vsk` — dedicated offline UI (when offline)
226
+ * 2. nearest route `network.vsk` — state-aware UI receiving
227
+ * `{ url, params, retry, online, effectiveType, downlink, rtt, saveData }`
228
+ * 3. router `offline` option (component or HTML string)
229
+ * 4. nearest route `error.vsk`, invoked with an `offline: true` error so
230
+ * existing error boundaries can style the offline case themselves
231
+ * 5. built-in default panel
232
+ *
233
+ * A network failure never renders the not-found page. While a boundary from
234
+ * steps 1–3 or 5 is displayed it re-renders on connectivity changes
235
+ * (3g→4g→offline…), and the router re-navigates automatically when the
236
+ * browser comes back online.
237
+ */
238
+ async function renderOfflinePage(router, match, container) {
239
+ const url = match.pathname || (typeof window !== 'undefined' ? window.location.pathname : '');
240
+ const chain = match.matchChain;
241
+ const navTokenAtRender = router._navToken;
242
+ // Debounced retry: a component that calls retry() while rendering must
243
+ // not trigger a navigate→fail→render→retry microtask loop.
244
+ const safeRetry = () => {
245
+ const r = router;
246
+ const now = Date.now();
247
+ if (now - (r._lastOfflineRetry || 0) < 300)
248
+ return;
249
+ r._lastOfflineRetry = now;
250
+ router.navigate(url, { replace: true });
251
+ };
252
+ const mountComponent = async (entry) => {
253
+ if (typeof entry === 'string') {
254
+ container.innerHTML = entry;
255
+ return true;
256
+ }
257
+ const tempRoot = document.createDocumentFragment();
258
+ const walker = createHydrateWalker(tempRoot, []);
259
+ const props = { url, params: match.params, retry: safeRetry, ...getNetworkState() };
260
+ const dom = await runInBlockWindow(() => entry(props, new Map(), walker));
261
+ if (dom && typeof dom === 'object' && dom.nodeType) {
262
+ if (container.replaceChildren)
263
+ container.replaceChildren(dom);
264
+ else {
265
+ container.innerHTML = '';
266
+ container.appendChild(dom);
267
+ }
268
+ return true;
269
+ }
270
+ if (typeof dom === 'string') {
271
+ container.innerHTML = dom;
272
+ return true;
273
+ }
274
+ return false;
275
+ };
276
+ let mounted = false;
277
+ let reactive = false; // re-render on connectivity change
278
+ const offlineEntry = findOfflineComponent(chain);
279
+ if (!mounted && offlineEntry && !(getNetworkState().online)) {
280
+ mounted = await mountComponent(offlineEntry);
281
+ reactive = mounted;
282
+ }
283
+ const networkEntry = findNetworkComponent(chain);
284
+ if (!mounted && networkEntry) {
285
+ mounted = await mountComponent(networkEntry);
286
+ reactive = mounted;
287
+ }
288
+ const optionUI = router._offlineUI;
289
+ if (!mounted && optionUI !== undefined && optionUI !== null) {
290
+ mounted = typeof optionUI === 'function'
291
+ ? await mountComponent(optionUI)
292
+ : ((container.innerHTML = String(optionUI)), true);
293
+ reactive = typeof optionUI === 'function';
294
+ }
295
+ const errorFn = findErrorComponent(chain);
296
+ if (!mounted && errorFn) {
297
+ // Existing error boundary opts in via `props.offline` /
298
+ // `props.networkState`.
299
+ const err = new Error('You are offline');
300
+ err.statusCode = 0;
301
+ err.offline = true;
302
+ await renderErrorPage(router, match, container, err);
303
+ mounted = true;
304
+ }
305
+ if (!mounted) {
306
+ container.innerHTML = defaultOfflineHtml(url);
307
+ const retryBtn = (container.querySelector ? container.querySelector('[data-vesk-offline-retry]') : null);
308
+ if (retryBtn)
309
+ retryBtn.addEventListener('click', safeRetry);
310
+ reactive = true;
311
+ }
312
+ router._showingOffline = true;
313
+ registerOnlineRecovery(router);
314
+ // Live-swap the displayed boundary when connectivity changes (e.g.
315
+ // offline → 3g → 4g), but stop as soon as a newer navigation takes over.
316
+ if (reactive && typeof window !== 'undefined') {
317
+ container.__veskNetworkUI = true;
318
+ const unsubscribe = watchNetwork(() => {
319
+ const stillCurrent = router._navToken === navTokenAtRender &&
320
+ container.__veskNetworkUI === true &&
321
+ router._showingOffline === true;
322
+ if (!stillCurrent) {
323
+ unsubscribe();
324
+ return;
325
+ }
326
+ void renderOfflinePage(router, match, container);
327
+ });
328
+ }
329
+ }
330
+ function registerOnlineRecovery(router) {
331
+ const r = router;
332
+ if (r._onlineHandler || typeof window === 'undefined' || typeof window.addEventListener !== 'function')
333
+ return;
334
+ r._onlineHandler = () => {
335
+ if (!r._showingOffline)
336
+ return;
337
+ r._showingOffline = false;
338
+ router.navigate(window.location.pathname + window.location.search, { replace: true });
339
+ };
340
+ window.addEventListener('online', r._onlineHandler);
341
+ }
161
342
  async function applyRouteData(router, match, data, container, render = renderMatch) {
343
+ if (data.offline) {
344
+ await renderOfflinePage(router, match, container);
345
+ return;
346
+ }
162
347
  if (data.notFound) {
163
348
  await renderNotFound(router, match, container);
164
349
  return;
@@ -229,7 +414,7 @@ function shouldFetchData(router, match) {
229
414
  return Date.now() - fetchedAt >= ttl;
230
415
  }
231
416
  function hasRealPageData(data) {
232
- if (data.notFound || data.redirect || data.error)
417
+ if (data.offline || data.notFound || data.redirect || data.error)
233
418
  return true;
234
419
  if (data.head && data.head.trim().length > 0)
235
420
  return true;
@@ -339,7 +524,27 @@ function replacePageContent(container, newContent) {
339
524
  }
340
525
  return true;
341
526
  }
342
- function renderMatch(router, match, container) {
527
+ function storePageInstance(router, pageNode, pageProps, result) {
528
+ if (!router || !pageNode._pageName || !result || result.nodeType !== 1)
529
+ return;
530
+ if (!router.__componentInstances)
531
+ router.__componentInstances = new Map();
532
+ const name = pageNode._pageName;
533
+ if (!router.__componentInstances.has(name))
534
+ router.__componentInstances.set(name, []);
535
+ router.__componentInstances.get(name).push({ root: result, props: pageProps, node: pageNode, type: 'page' });
536
+ }
537
+ function storeLayoutInstance(router, node, layoutProps, result) {
538
+ if (!router || !node._layoutName || !result || result.nodeType !== 1)
539
+ return;
540
+ if (!router.__componentInstances)
541
+ router.__componentInstances = new Map();
542
+ const name = node._layoutName;
543
+ if (!router.__componentInstances.has(name))
544
+ router.__componentInstances.set(name, []);
545
+ router.__componentInstances.get(name).push({ root: result, props: layoutProps, node, type: 'layout' });
546
+ }
547
+ function renderMatch(router, match, container, navToken) {
343
548
  const chain = match.matchChain;
344
549
  const paramValues = match.params;
345
550
  let pageNode = findPageNodeOrFailed(match);
@@ -379,11 +584,25 @@ function renderMatch(router, match, container) {
379
584
  return;
380
585
  }
381
586
  // The route matched and has a page name, but its component chunk failed
382
- // to load or compile. Render the route's error boundary (or a generic
383
- // error page) without touching any other route's components.
384
- if (!pageNode.page && pageNode._pageName) {
385
- const chunkErr = chunkErrorForNode(pageNode);
386
- return renderErrorPage(router, match, container, chunkErr || new Error(`Component "${pageNode._pageName}" is unavailable`));
587
+ // to load or compile (or its component ref is still an unwired name).
588
+ // While offline that is a connectivity problem, not a broken route —
589
+ // show the offline experience instead of an error page. The connectivity
590
+ // probe only runs when a chunk actually failed or the browser reports
591
+ // offline, so ordinary lazy-loading never pays for it.
592
+ if (pageNode._pageName && (!pageNode.page || typeof pageNode.page === 'string')) {
593
+ const suspectOffline = failedChunks.has(pageNode._chunk)
594
+ || (typeof navigator !== 'undefined' && !!navigator && navigator.onLine === false);
595
+ navDebug('unresolved page guard', String(pageNode._pageName), 'suspect=', String(suspectOffline));
596
+ if (!suspectOffline) {
597
+ const chunkErr0 = chunkErrorForNode(pageNode);
598
+ return renderErrorPage(router, match, container, chunkErr0 || new Error(`Component "${pageNode._pageName}" is unavailable`));
599
+ }
600
+ return looksOffline().then((offline) => {
601
+ if (offline)
602
+ return renderOfflinePage(router, match, container);
603
+ const chunkErr = chunkErrorForNode(pageNode);
604
+ return renderErrorPage(router, match, container, chunkErr || new Error(`Component "${pageNode._pageName}" is unavailable`));
605
+ });
387
606
  }
388
607
  const layoutNodes = chain.filter(n => n.layout);
389
608
  const tempRoot = document.createDocumentFragment();
@@ -434,7 +653,10 @@ function renderMatch(router, match, container) {
434
653
  const pageStep = () => {
435
654
  set(_state.params, paramValues);
436
655
  set(_state.path, match.pathname || window.location.pathname);
437
- set(_state.search, window.location.search || '');
656
+ {
657
+ const s = window.location.search || '';
658
+ set(_state.search, s.startsWith('?') ? s.slice(1) : s);
659
+ }
438
660
  const pageProps = { params: paramValues, ...pageNode.props };
439
661
  const result = pageNode.page(pageProps, new Map(), clientWalker);
440
662
  if (result && typeof result.then === 'function') {
@@ -457,7 +679,7 @@ function renderMatch(router, match, container) {
457
679
  router.navigate(window.location.pathname, { replace: true });
458
680
  }
459
681
  };
460
- const errorProps = { error, retry, params: paramValues, statusCode: error?.statusCode ?? 500, stack: error instanceof Error ? error.stack : String(error), url: typeof window !== 'undefined' ? window.location.pathname : '' };
682
+ const errorProps = { error, retry, params: paramValues, statusCode: error?.statusCode ?? 500, stack: error instanceof Error ? error.stack : String(error), url: typeof window !== 'undefined' ? window.location.pathname : '', offline: error?.offline === true, networkState: getNetworkState() };
461
683
  return runInBlockWindow(() => renderLayoutWith(0, () => {
462
684
  const result = errorFn(errorProps, new Map(), clientWalker);
463
685
  if (result && typeof result.then === 'function') {
@@ -478,7 +700,7 @@ function renderMatch(router, match, container) {
478
700
  router.navigate(window.location.pathname, { replace: true });
479
701
  }
480
702
  };
481
- const errorProps = { error, retry, params: paramValues, statusCode: error?.statusCode ?? 500, stack: error instanceof Error ? error.stack : String(error), url: typeof window !== 'undefined' ? window.location.pathname : '' };
703
+ const errorProps = { error, retry, params: paramValues, statusCode: error?.statusCode ?? 500, stack: error instanceof Error ? error.stack : String(error), url: typeof window !== 'undefined' ? window.location.pathname : '', offline: error?.offline === true, networkState: getNetworkState() };
482
704
  const errDom = runInBlockWindow(() => errorFn(errorProps, new Map(), clientWalker));
483
705
  if (errDom && typeof errDom.then === 'function') {
484
706
  errDom.then(mountDom);
@@ -487,27 +709,14 @@ function renderMatch(router, match, container) {
487
709
  mountDom(errDom);
488
710
  }
489
711
  }
490
- function storePageInstance(router, pageNode, pageProps, result) {
491
- if (!router || !pageNode._pageName || !result || result.nodeType !== 1)
492
- return;
493
- if (!router.__componentInstances)
494
- router.__componentInstances = new Map();
495
- const name = pageNode._pageName;
496
- if (!router.__componentInstances.has(name))
497
- router.__componentInstances.set(name, []);
498
- router.__componentInstances.get(name).push({ root: result, props: pageProps, node: pageNode, type: 'page' });
499
- }
500
- function storeLayoutInstance(router, node, layoutProps, result) {
501
- if (!router || !node._layoutName || !result || result.nodeType !== 1)
502
- return;
503
- if (!router.__componentInstances)
504
- router.__componentInstances = new Map();
505
- const name = node._layoutName;
506
- if (!router.__componentInstances.has(name))
507
- router.__componentInstances.set(name, []);
508
- router.__componentInstances.get(name).push({ root: result, props: layoutProps, node, type: 'layout' });
509
- }
510
712
  const mountDom = (dom) => {
713
+ // Staleness guard: a suspended (async) page whose render resolves after
714
+ // a NEWER navigation must not clobber the newer page's DOM. The file
715
+ // router stamps the container with the latest nav token per navigation.
716
+ if (navToken !== undefined &&
717
+ container.__veskMountToken !== navToken) {
718
+ return;
719
+ }
511
720
  if (dom && typeof dom === 'object' && dom.nodeType) {
512
721
  if (container.replaceChildren)
513
722
  container.replaceChildren(dom);
@@ -566,7 +775,13 @@ function makeSsrError(message) {
566
775
  // failure or when the SSR output is already an error page). The error
567
776
  // component renders in place of the page inside the layout chain so the error
568
777
  // page keeps the site nav.
778
+ function clearOfflineFlag(router, container) {
779
+ router._showingOffline = false;
780
+ if (container)
781
+ container.__veskNetworkUI = false;
782
+ }
569
783
  async function renderErrorPage(router, match, container, error) {
784
+ clearOfflineFlag(router, container);
570
785
  const chain = match.matchChain;
571
786
  const paramValues = match.params;
572
787
  const errorFn = findErrorComponent(chain);
@@ -586,7 +801,7 @@ async function renderErrorPage(router, match, container, error) {
586
801
  router.navigate(window.location.pathname, { replace: true });
587
802
  }
588
803
  };
589
- const errorProps = { error, retry, params: paramValues, statusCode: error?.statusCode ?? 500, stack: error instanceof Error ? error.stack : String(error), url: typeof window !== 'undefined' ? window.location.pathname : '' };
804
+ const errorProps = { error, retry, params: paramValues, statusCode: error?.statusCode ?? 500, stack: error instanceof Error ? error.stack : String(error), url: typeof window !== 'undefined' ? window.location.pathname : '', offline: error?.offline === true, networkState: getNetworkState() };
590
805
  const renderErrorChain = (index) => {
591
806
  if (index >= layoutNodes.length) {
592
807
  const result = errorFn(errorProps, new Map(), walker);
@@ -660,7 +875,10 @@ async function hydrateInitial(router, match, container, strategy) {
660
875
  })();
661
876
  set(_state.params, paramValues);
662
877
  set(_state.path, match.pathname || window.location.pathname);
663
- set(_state.search, window.location.search || '');
878
+ {
879
+ const s = window.location.search || '';
880
+ set(_state.search, s.startsWith('?') ? s.slice(1) : s);
881
+ }
664
882
  if (ssrErrorMarker !== null) {
665
883
  let message = 'Internal Server Error';
666
884
  if (ssrErrorMarker.startsWith(':')) {
@@ -677,8 +895,13 @@ async function hydrateInitial(router, match, container, strategy) {
677
895
  const layoutNodes = chain.filter(n => n.layout);
678
896
  const hydrators = router.__hydrators;
679
897
  if (!pageNode.page && !(hydrators && pageNode._pageName && hydrators[pageNode._pageName])) {
680
- // The route's component chunk failed to load or compile. Render the
681
- // error boundary for this route instead of hydrating nothing.
898
+ // The route's component chunk failed to load or compile. While
899
+ // offline that is a connectivity problem — show the offline
900
+ // experience; otherwise render the error boundary for this route.
901
+ if (await looksOffline()) {
902
+ await renderOfflinePage(router, match, container);
903
+ return;
904
+ }
682
905
  const chunkErr = chunkErrorForNode(pageNode);
683
906
  await renderErrorPage(router, match, container, chunkErr || new Error(`Component "${pageNode._pageName}" is unavailable`));
684
907
  return;
@@ -698,7 +921,10 @@ async function hydrateInitial(router, match, container, strategy) {
698
921
  if (!strategy || strategy === 'full') {
699
922
  const walker = createHydrateWalker(container);
700
923
  setIsHydrating(true);
701
- await runInBlockWindow(() => hydPage(pageProps, new Map(), walker));
924
+ await runInBlockWindow(() => {
925
+ const result = hydPage(pageProps, new Map(), walker);
926
+ storePageInstance(router, pageNode, pageProps, result);
927
+ });
702
928
  setIsHydrating(false);
703
929
  }
704
930
  else if (strategy === 'viewport') {
@@ -723,7 +949,10 @@ async function hydrateInitial(router, match, container, strategy) {
723
949
  if (index >= layoutNodes.length) {
724
950
  return (subWalker) => {
725
951
  if (!strategy || strategy === 'full') {
726
- return hydPage({ params: paramValues, ...pageNode.props }, new Map(), subWalker);
952
+ const pageProps = { params: paramValues, ...pageNode.props };
953
+ const result = hydPage(pageProps, new Map(), subWalker);
954
+ storePageInstance(router, pageNode, pageProps, result);
955
+ return result;
727
956
  }
728
957
  else if (strategy === 'viewport') {
729
958
  hydrateViewport(subWalker.root, hydPage, { params: paramValues, ...pageNode.props });
@@ -741,7 +970,9 @@ async function hydrateInitial(router, match, container, strategy) {
741
970
  const hydLayout = hydLayouts[index];
742
971
  const childHydrator = renderLayoutChain(index + 1);
743
972
  const layoutProps = { children: childHydrator, params: paramValues };
744
- return hydLayout(layoutProps, new Map(), walker);
973
+ const result = hydLayout(layoutProps, new Map(), walker);
974
+ storeLayoutInstance(router, node, layoutProps, result);
975
+ return result;
745
976
  }
746
977
  const walker = createHydrateWalker(container);
747
978
  await runInBlockWindow(() => renderLayoutChain(0));
@@ -764,6 +995,20 @@ async function hydrateInitial(router, match, container, strategy) {
764
995
  }
765
996
  }
766
997
  }
998
+ function scrollToHash() {
999
+ const h = typeof window !== 'undefined' ? window.location.hash : '';
1000
+ if (!h || h === '#')
1001
+ return;
1002
+ const id = h.slice(1);
1003
+ setTimeout(() => {
1004
+ try {
1005
+ const el = document.getElementById(id);
1006
+ if (el && typeof el.scrollIntoView === 'function')
1007
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
1008
+ }
1009
+ catch { /* element may not exist */ }
1010
+ }, 80);
1011
+ }
767
1012
  export function createRouter(routes, options = {}) {
768
1013
  const container = options.container || document.getElementById('root');
769
1014
  const prefetch = options.prefetch !== false;
@@ -777,6 +1022,51 @@ export function createRouter(routes, options = {}) {
777
1022
  _currentSegments: null,
778
1023
  _depth: 0,
779
1024
  _routeDataCache: options.routeDataCache ?? 0,
1025
+ _offlineUI: (options.offline ?? null),
1026
+ _beforeGuards: [],
1027
+ _viewTransitions: options.viewTransitions === true,
1028
+ beforeEach(fn) {
1029
+ this._beforeGuards.push(fn);
1030
+ let active = true;
1031
+ return () => {
1032
+ if (!active)
1033
+ return;
1034
+ active = false;
1035
+ const guards = this._beforeGuards;
1036
+ const i = guards.indexOf(fn);
1037
+ if (i > -1)
1038
+ guards.splice(i, 1);
1039
+ };
1040
+ },
1041
+ _runGuards(to) {
1042
+ const from = window.location.pathname;
1043
+ for (const g of this._beforeGuards ?? []) {
1044
+ const d = g(to, from);
1045
+ if (d instanceof Promise) {
1046
+ return d.then((r) => {
1047
+ if (r === false || typeof r === 'string')
1048
+ return r;
1049
+ });
1050
+ }
1051
+ if (d === false || typeof d === 'string')
1052
+ return d;
1053
+ }
1054
+ },
1055
+ go(n) {
1056
+ window.history.go(n);
1057
+ },
1058
+ get route() {
1059
+ const m = this._currentMatch;
1060
+ if (!m)
1061
+ return null;
1062
+ const chain = m.matchChain ?? [];
1063
+ const deepest = chain[chain.length - 1];
1064
+ return {
1065
+ pathname: m.pathname || '',
1066
+ params: m.params,
1067
+ pattern: deepest?.fullPath || '/',
1068
+ };
1069
+ },
780
1070
  start() {
781
1071
  setCurrentRouter(this);
782
1072
  if (typeof window !== 'undefined' && 'scrollRestoration' in window.history) {
@@ -795,24 +1085,52 @@ export function createRouter(routes, options = {}) {
795
1085
  }, 100);
796
1086
  }, { passive: true });
797
1087
  }
1088
+ // Navigation policy: SPA navigation happens through <Link>/
1089
+ // <NavLink>/navigate() and opt-in plain anchors
1090
+ // `<a href="..." no-reload>`. Plain <a href> without `no-reload`
1091
+ // (or `data-no-reload`) always does native browser navigation.
1092
+ // `no-reload` anchors are intercepted via a delegated document
1093
+ // click listener that respects modifier keys, target="_blank",
1094
+ // download, external origins, hash/mailto/tel schemes.
1095
+ window.addEventListener('popstate', () => {
1096
+ setIsPopStateNavigation(true);
1097
+ this.navigate(window.location.href, { replace: true });
1098
+ });
798
1099
  document.addEventListener('click', (e) => {
799
- const link = e.target?.nodeType === 1 ? e.target.closest('a[href]') : null;
800
- if (!link)
1100
+ const target = e.target;
1101
+ if (!target || target.nodeType !== 1)
1102
+ return;
1103
+ const anchor = target.closest('a[href]');
1104
+ if (!anchor)
1105
+ return;
1106
+ if (!anchor.hasAttribute('no-reload') && !anchor.hasAttribute('data-no-reload'))
1107
+ return;
1108
+ if (e.defaultPrevented)
1109
+ return;
1110
+ const me = e;
1111
+ if (me.metaKey || me.ctrlKey || me.shiftKey || me.altKey || me.button !== 0)
801
1112
  return;
802
- if (link.hostname && link.hostname !== window.location.hostname)
1113
+ if (anchor.target === '_blank')
803
1114
  return;
804
- const href = link.getAttribute('href');
805
- if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:'))
1115
+ if (anchor.hasAttribute('download'))
806
1116
  return;
807
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0)
1117
+ if (anchor.getAttribute('rel') === 'external')
808
1118
  return;
1119
+ const href = anchor.getAttribute('href');
1120
+ if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('javascript:'))
1121
+ return;
1122
+ try {
1123
+ const url = new URL(href, window.location.origin);
1124
+ if (url.origin !== window.location.origin)
1125
+ return;
1126
+ }
1127
+ catch {
1128
+ return;
1129
+ }
809
1130
  e.preventDefault();
1131
+ e.stopPropagation();
810
1132
  this.navigate(href);
811
1133
  });
812
- window.addEventListener('popstate', () => {
813
- setIsPopStateNavigation(true);
814
- this.navigate(window.location.href, { replace: true });
815
- });
816
1134
  if (prefetch) {
817
1135
  document.addEventListener('mouseenter', (e) => {
818
1136
  const link = e.target?.nodeType === 1 ? e.target.closest('a[href]') : null;
@@ -846,74 +1164,133 @@ export function createRouter(routes, options = {}) {
846
1164
  return;
847
1165
  }
848
1166
  match.pathname = url.pathname;
849
- if (!_isPopStateNavigation) {
850
- _scrollPositions.set(window.location.pathname, window.scrollY);
851
- }
852
- const loadingFn = findLoadingComponent(match.matchChain);
853
- this._navToken = (this._navToken || 0) + 1;
854
- const navToken = this._navToken;
855
- let firstRenderFailed = false;
856
- const updateUrl = () => {
857
- if (!opts.replace) {
858
- window.history.pushState({ path: url.pathname }, '', url.pathname);
1167
+ // Navigation guards (programmatic/Link navigations only).
1168
+ const self = this;
1169
+ const guardResult = self._runGuards(url.pathname);
1170
+ const proceedNav = () => {
1171
+ if (!_isPopStateNavigation) {
1172
+ _scrollPositions.set(window.location.pathname, window.scrollY);
859
1173
  }
860
- else {
861
- window.history.replaceState({ path: url.pathname }, '', url.pathname);
862
- }
863
- set(_state.path, url.pathname);
864
- set(_state.search, url.search);
865
- };
866
- const renderContent = () => {
867
- try {
868
- renderMatch(this, match, this.container);
1174
+ const loadingFn = findLoadingComponent(match.matchChain);
1175
+ this._navToken = (this._navToken || 0) + 1;
1176
+ const navToken = this._navToken;
1177
+ loadingStart();
1178
+ let firstRenderFailed = false;
1179
+ const updateUrl = () => {
1180
+ if (!opts.replace) {
1181
+ window.history.pushState({ path: url.pathname }, '', url.pathname + url.search);
1182
+ }
1183
+ else {
1184
+ window.history.replaceState({ path: url.pathname }, '', url.pathname + url.search);
1185
+ }
1186
+ set(_state.path, url.pathname);
1187
+ set(_state.search, url.search.startsWith('?') ? url.search.slice(1) : url.search);
1188
+ };
1189
+ const renderContent = () => {
1190
+ try {
1191
+ renderMatch(this, match, this.container);
1192
+ }
1193
+ catch (e) {
1194
+ firstRenderFailed = true;
1195
+ throw e;
1196
+ }
1197
+ this._currentMatch = match;
1198
+ clearOfflineFlag(this, container);
1199
+ handleScroll(url.pathname, opts.replace);
1200
+ };
1201
+ const swapView = (fn) => {
1202
+ const d = document;
1203
+ if (this._viewTransitions && typeof d.startViewTransition === 'function')
1204
+ d.startViewTransition(fn);
1205
+ else
1206
+ fn();
1207
+ };
1208
+ const doRender = () => {
1209
+ updateUrl();
1210
+ swapView(() => renderContent());
1211
+ scrollToHash();
1212
+ };
1213
+ const fetchData = () => {
1214
+ if (firstRenderFailed)
1215
+ return;
1216
+ if (!shouldFetchData(this, match)) {
1217
+ loadingFinish();
1218
+ scrollToHash();
1219
+ return;
1220
+ }
1221
+ getRouteData(url.pathname + url.search, 'nav' + navToken).then(async (data) => {
1222
+ if (navToken !== this._navToken)
1223
+ return;
1224
+ if (!data) {
1225
+ loadingFinish();
1226
+ return;
1227
+ }
1228
+ if (data.redirect) {
1229
+ loadingFinish();
1230
+ this.navigate(data.redirect, { replace: true });
1231
+ return;
1232
+ }
1233
+ if (!hasRealPageData(data)) {
1234
+ loadingFinish();
1235
+ scrollToHash();
1236
+ return;
1237
+ }
1238
+ await swapView(() => applyRouteData(this, match, data, this.container));
1239
+ scrollToHash();
1240
+ loadingFinish({ error: !!data.error });
1241
+ }).catch(() => loadingFinish({ error: true }));
1242
+ };
1243
+ if (loadingFn) {
1244
+ showLoadingInContainer(this.container, loadingFn, match.params);
1245
+ Promise.resolve().then(() => { try {
1246
+ doRender();
1247
+ }
1248
+ finally {
1249
+ fetchData();
1250
+ } });
869
1251
  }
870
- catch (e) {
871
- firstRenderFailed = true;
872
- throw e;
1252
+ else {
1253
+ try {
1254
+ doRender();
1255
+ }
1256
+ finally {
1257
+ fetchData();
1258
+ }
873
1259
  }
874
- this._currentMatch = match;
875
- handleScroll(url.pathname, opts.replace);
876
- };
877
- const doRender = () => {
878
- updateUrl();
879
- renderContent();
880
1260
  };
881
- const fetchData = () => {
882
- if (firstRenderFailed)
883
- return;
884
- if (!shouldFetchData(this, match))
885
- return;
886
- getRouteData(url.pathname + url.search, 'nav' + navToken).then(async (data) => {
887
- if (navToken !== this._navToken)
888
- return;
889
- if (!data)
1261
+ // If guards return a Promise, chain the rest asynchronously.
1262
+ // If sync (the common case), the navigation completes synchronously,
1263
+ // preserving backward compatibility with callers that don't await.
1264
+ if (guardResult instanceof Promise) {
1265
+ return guardResult.then((decision) => {
1266
+ if (decision === false)
890
1267
  return;
891
- if (data.redirect) {
892
- this.navigate(data.redirect, { replace: true });
1268
+ if (typeof decision === 'string' && decision !== url.pathname && (self._guardDepth ?? 0) < 5) {
1269
+ self._guardDepth = (self._guardDepth ?? 0) + 1;
1270
+ try {
1271
+ self.navigate(decision, { replace: true });
1272
+ }
1273
+ finally {
1274
+ self._guardDepth = 0;
1275
+ }
893
1276
  return;
894
1277
  }
895
- if (!hasRealPageData(data))
896
- return;
897
- await applyRouteData(this, match, data, this.container);
1278
+ proceedNav();
898
1279
  });
899
- };
900
- if (loadingFn) {
901
- showLoadingInContainer(this.container, loadingFn, match.params);
902
- Promise.resolve().then(() => { try {
903
- doRender();
904
- }
905
- finally {
906
- fetchData();
907
- } });
908
1280
  }
909
- else {
1281
+ if (guardResult === false)
1282
+ return;
1283
+ if (typeof guardResult === 'string' && guardResult !== url.pathname && (self._guardDepth ?? 0) < 5) {
1284
+ self._guardDepth = (self._guardDepth ?? 0) + 1;
910
1285
  try {
911
- doRender();
1286
+ self.navigate(guardResult, { replace: true });
912
1287
  }
913
1288
  finally {
914
- fetchData();
1289
+ self._guardDepth = 0;
915
1290
  }
1291
+ return;
916
1292
  }
1293
+ proceedNav();
917
1294
  },
918
1295
  prefetch(path) {
919
1296
  const url = new URL(path, window.location.origin);
@@ -933,6 +1310,10 @@ export function createRouter(routes, options = {}) {
933
1310
  get currentPath() {
934
1311
  return get(_state.path);
935
1312
  },
1313
+ get isLoading() {
1314
+ const cells = getLoadingState();
1315
+ return Boolean(get(cells.isLoading));
1316
+ },
936
1317
  hmrUpdate() {
937
1318
  const updated = globalThis.__updatedComponents;
938
1319
  if (!updated || updated.size === 0)
@@ -996,6 +1377,51 @@ export function createFileRouter(routeTree, options = {}) {
996
1377
  _currentSegments: null,
997
1378
  _depth: 0,
998
1379
  _routeDataCache: options.routeDataCache ?? 0,
1380
+ _offlineUI: (options.offline ?? null),
1381
+ _beforeGuards: [],
1382
+ _viewTransitions: options.viewTransitions === true,
1383
+ beforeEach(fn) {
1384
+ this._beforeGuards.push(fn);
1385
+ let active = true;
1386
+ return () => {
1387
+ if (!active)
1388
+ return;
1389
+ active = false;
1390
+ const guards = this._beforeGuards;
1391
+ const i = guards.indexOf(fn);
1392
+ if (i > -1)
1393
+ guards.splice(i, 1);
1394
+ };
1395
+ },
1396
+ _runGuards(to) {
1397
+ const from = window.location.pathname;
1398
+ for (const g of this._beforeGuards ?? []) {
1399
+ const d = g(to, from);
1400
+ if (d instanceof Promise) {
1401
+ return d.then((r) => {
1402
+ if (r === false || typeof r === 'string')
1403
+ return r;
1404
+ });
1405
+ }
1406
+ if (d === false || typeof d === 'string')
1407
+ return d;
1408
+ }
1409
+ },
1410
+ go(n) {
1411
+ window.history.go(n);
1412
+ },
1413
+ get route() {
1414
+ const m = router._currentMatch;
1415
+ if (!m)
1416
+ return null;
1417
+ const chain = m.matchChain ?? [];
1418
+ const deepest = chain[chain.length - 1];
1419
+ return {
1420
+ pathname: m.pathname || '',
1421
+ params: m.params,
1422
+ pattern: deepest?.fullPath || '/',
1423
+ };
1424
+ },
999
1425
  start() {
1000
1426
  setCurrentRouter(this);
1001
1427
  if (typeof window !== 'undefined' && 'scrollRestoration' in window.history) {
@@ -1019,6 +1445,41 @@ export function createFileRouter(routeTree, options = {}) {
1019
1445
  setIsPopStateNavigation(true);
1020
1446
  router.navigate(window.location.pathname + window.location.search, { replace: true });
1021
1447
  });
1448
+ document.addEventListener('click', (e) => {
1449
+ const target = e.target;
1450
+ if (!target || target.nodeType !== 1)
1451
+ return;
1452
+ const anchor = target.closest('a[href]');
1453
+ if (!anchor)
1454
+ return;
1455
+ if (!anchor.hasAttribute('no-reload') && !anchor.hasAttribute('data-no-reload'))
1456
+ return;
1457
+ if (e.defaultPrevented)
1458
+ return;
1459
+ const me = e;
1460
+ if (me.metaKey || me.ctrlKey || me.shiftKey || me.altKey || me.button !== 0)
1461
+ return;
1462
+ if (anchor.target === '_blank')
1463
+ return;
1464
+ if (anchor.hasAttribute('download'))
1465
+ return;
1466
+ if (anchor.getAttribute('rel') === 'external')
1467
+ return;
1468
+ const href = anchor.getAttribute('href');
1469
+ if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('javascript:'))
1470
+ return;
1471
+ try {
1472
+ const url = new URL(href, window.location.origin);
1473
+ if (url.origin !== window.location.origin)
1474
+ return;
1475
+ }
1476
+ catch {
1477
+ return;
1478
+ }
1479
+ e.preventDefault();
1480
+ e.stopPropagation();
1481
+ router.navigate(href);
1482
+ });
1022
1483
  if (options.prefetch !== false) {
1023
1484
  document.addEventListener('mouseenter', (e) => {
1024
1485
  const link = e.target?.nodeType === 1 ? e.target.closest('a[href]') : null;
@@ -1074,148 +1535,225 @@ export function createFileRouter(routeTree, options = {}) {
1074
1535
  return;
1075
1536
  }
1076
1537
  match.pathname = url.pathname;
1077
- if (!_isPopStateNavigation) {
1078
- _scrollPositions.set(window.location.pathname, window.scrollY);
1079
- }
1080
- const loadingFn = findLoadingComponent(match.matchChain);
1081
- router._navToken = (router._navToken || 0) + 1;
1082
- const navToken = router._navToken;
1083
- const middlewareFns = Array.isArray(middleware) ? middleware : (middleware ? [middleware] : []);
1084
- let firstRenderFailed = false;
1085
- const updateUrl = () => {
1086
- const fullUrl = url.pathname + url.search;
1087
- if (!opts.replace) {
1088
- window.history.pushState({ path: fullUrl }, '', fullUrl);
1089
- }
1090
- else {
1091
- window.history.replaceState({ path: fullUrl }, '', fullUrl);
1092
- }
1093
- set(_state.path, url.pathname);
1094
- set(_state.search, url.search);
1095
- };
1096
- const renderContent = () => {
1097
- // Data isolation: route nodes are shared singletons. When the
1098
- // navigation changes the path a node was last hydrated with, its
1099
- // cached props must not render on the new path (a params-only or
1100
- // failed data payload would otherwise merge stale foreign data
1101
- // into the new page).
1102
- const propsNode = findPageNode(match);
1103
- if (propsNode && propsNode._dataPath !== url.pathname) {
1104
- propsNode.props = undefined;
1105
- }
1106
- try {
1107
- renderFn(router, match, container);
1108
- }
1109
- catch (e) {
1110
- firstRenderFailed = true;
1111
- throw e;
1538
+ const afterGuards = () => {
1539
+ if (!_isPopStateNavigation) {
1540
+ _scrollPositions.set(window.location.pathname, window.scrollY);
1112
1541
  }
1113
- router._currentMatch = match;
1114
- handleScroll(url.pathname, opts.replace);
1115
- };
1116
- const doRender = () => {
1117
- updateUrl();
1118
- renderContent();
1119
- };
1120
- const fetchData = () => {
1121
- if (firstRenderFailed)
1122
- return;
1123
- if (!shouldFetchData(router, match))
1124
- return;
1125
- getRouteData(url.pathname + url.search, 'nav' + navToken).then(async (data) => {
1126
- if (navToken !== router._navToken)
1127
- return;
1128
- if (!data)
1542
+ const swapView = (fn) => {
1543
+ const d = document;
1544
+ if (router._viewTransitions && typeof d.startViewTransition === 'function')
1545
+ d.startViewTransition(fn);
1546
+ else
1547
+ fn();
1548
+ };
1549
+ const loadingFn = findLoadingComponent(match.matchChain);
1550
+ router._navToken = (router._navToken || 0) + 1;
1551
+ navDebug('navigate', url.pathname, 'token=' + router._navToken, 'pendingChunks', hasPendingChunks(match.matchChain));
1552
+ const navToken = router._navToken;
1553
+ loadingStart();
1554
+ const middlewareFns = Array.isArray(middleware) ? middleware : (middleware ? [middleware] : []);
1555
+ let firstRenderFailed = false;
1556
+ const updateUrl = () => {
1557
+ const fullUrl = url.pathname + url.search;
1558
+ if (!opts.replace) {
1559
+ window.history.pushState({ path: fullUrl }, '', fullUrl);
1560
+ }
1561
+ else {
1562
+ window.history.replaceState({ path: fullUrl }, '', fullUrl);
1563
+ }
1564
+ set(_state.path, url.pathname);
1565
+ set(_state.search, url.search.startsWith('?') ? url.search.slice(1) : url.search);
1566
+ };
1567
+ const renderContent = () => {
1568
+ const propsNode = findPageNode(match);
1569
+ if (propsNode && propsNode._dataPath !== url.pathname) {
1570
+ propsNode.props = undefined;
1571
+ }
1572
+ try {
1573
+ container.__veskMountToken = navToken;
1574
+ renderFn(router, match, container, navToken);
1575
+ }
1576
+ catch (e) {
1577
+ firstRenderFailed = true;
1578
+ throw e;
1579
+ }
1580
+ router._currentMatch = match;
1581
+ clearOfflineFlag(router, container);
1582
+ handleScroll(url.pathname, opts.replace);
1583
+ };
1584
+ const doRender = () => {
1585
+ navDebug('doRender', url.pathname, 'token=' + navToken);
1586
+ updateUrl();
1587
+ swapView(() => renderContent());
1588
+ scrollToHash();
1589
+ navDebug('painted', url.pathname);
1590
+ };
1591
+ const fetchData = () => {
1592
+ if (firstRenderFailed)
1129
1593
  return;
1130
- if (data.redirect) {
1131
- router.navigate(data.redirect, { replace: true });
1594
+ navDebug('fetchData?', url.pathname, String(shouldFetchData(router, match)));
1595
+ if (!shouldFetchData(router, match)) {
1596
+ loadingFinish();
1597
+ scrollToHash();
1132
1598
  return;
1133
1599
  }
1134
- if (!hasRealPageData(data))
1135
- return;
1136
- const pending = hasPendingChunks(match.matchChain);
1137
- if (pending.length > 0) {
1138
- await loadChunksQuietly(pending);
1600
+ getRouteData(url.pathname + url.search, 'nav' + navToken).then(async (data) => {
1601
+ navDebug('data arrived', url.pathname, 'token=' + navToken, 'current=' + router._navToken, 'realProps', JSON.stringify(data && hasRealPageData(data)));
1139
1602
  if (navToken !== router._navToken)
1140
1603
  return;
1141
- if (typeof router.__updateComponents === 'function') {
1142
- router.__updateComponents(match.matchChain);
1604
+ if (!data) {
1605
+ loadingFinish();
1606
+ return;
1143
1607
  }
1144
- }
1145
- await applyRouteData(router, match, data, container, renderFn);
1146
- });
1147
- };
1148
- const pendingChunks = hasPendingChunks(match.matchChain);
1149
- const doRenderWithChunks = pendingChunks.length > 0
1150
- ? () => {
1151
- updateUrl();
1152
- return loadChunksQuietly(pendingChunks).then(() => {
1153
- // A newer navigation (e.g. popstate back while the chunk
1154
- // was still loading) may have superseded this one; don't
1155
- // paint the stale route over the current URL.
1156
- if (navToken !== router._navToken)
1608
+ if (data.redirect) {
1609
+ loadingFinish();
1610
+ router.navigate(data.redirect, { replace: true });
1157
1611
  return;
1612
+ }
1613
+ if (!hasRealPageData(data)) {
1614
+ loadingFinish();
1615
+ return;
1616
+ }
1617
+ const pending = hasPendingChunks(match.matchChain);
1618
+ if (pending.length > 0) {
1619
+ await loadChunksQuietly(pending);
1620
+ if (navToken !== router._navToken)
1621
+ return;
1622
+ if (typeof router.__updateComponents === 'function') {
1623
+ router.__updateComponents(match.matchChain);
1624
+ }
1625
+ }
1626
+ await swapView(() => applyRouteData(router, match, data, container, renderFn));
1627
+ scrollToHash();
1628
+ loadingFinish({ error: !!data.error });
1629
+ }).catch(() => loadingFinish({ error: true }));
1630
+ };
1631
+ const pendingChunks = hasPendingChunks(match.matchChain);
1632
+ const handleNavFailure = (err) => {
1633
+ navDebug('handleNavFailure', String(err?.message || err).slice(0, 80));
1634
+ if (navToken !== router._navToken)
1635
+ return;
1636
+ loadingFinish({ error: true });
1637
+ looksOffline().then((offline) => {
1638
+ navDebug('handleNavFailure verdict', offline ? 'offline' : 'online');
1639
+ if (offline)
1640
+ return void renderOfflinePage(router, match, container);
1641
+ return void renderErrorPage(router, match, container, err);
1642
+ }).catch(() => { });
1643
+ };
1644
+ const doRenderWithChunks = pendingChunks.length > 0
1645
+ ? () => {
1646
+ updateUrl();
1647
+ return loadChunksQuietly(pendingChunks).then(() => {
1648
+ if (navToken !== router._navToken)
1649
+ return;
1650
+ if (typeof router.__updateComponents === 'function') {
1651
+ router.__updateComponents(match.matchChain);
1652
+ }
1653
+ try {
1654
+ renderContent();
1655
+ loadingFinish();
1656
+ }
1657
+ catch (e) {
1658
+ handleNavFailure(e);
1659
+ }
1660
+ }, (e) => {
1661
+ handleNavFailure(e);
1662
+ });
1663
+ }
1664
+ : (() => {
1158
1665
  if (typeof router.__updateComponents === 'function') {
1159
1666
  router.__updateComponents(match.matchChain);
1160
1667
  }
1161
- renderContent();
1668
+ doRender();
1162
1669
  });
1163
- }
1164
- : (() => { doRender(); });
1165
- async function runMwChain(index) {
1166
- if (index >= middlewareFns.length) {
1167
- await doRenderWithChunks();
1168
- return;
1169
- }
1170
- const fn = middlewareFns[index];
1171
- const ctx = { url: url.pathname, params: match.params, router, locals: {} };
1172
- async function next(rewrite) {
1173
- if (rewrite) {
1174
- match.pathname = rewrite;
1175
- url.pathname = rewrite;
1176
- }
1177
- return runMwChain(index + 1);
1178
- }
1179
- try {
1180
- const result = await fn(ctx, next);
1181
- if (result && result.redirect) {
1182
- router.navigate(result.redirect, { replace: true });
1670
+ async function runMwChain(index) {
1671
+ if (index >= middlewareFns.length) {
1672
+ await doRenderWithChunks();
1183
1673
  return;
1184
1674
  }
1185
- }
1186
- catch (e) {
1187
- if (e && e.name === 'Redirect') {
1188
- router.navigate(e.url, { replace: true });
1189
- return;
1675
+ const fn = middlewareFns[index];
1676
+ const ctx = { url: url.pathname, params: match.params, router, locals: {} };
1677
+ async function next(rewrite) {
1678
+ if (rewrite) {
1679
+ match.pathname = rewrite;
1680
+ url.pathname = rewrite;
1681
+ }
1682
+ return runMwChain(index + 1);
1190
1683
  }
1191
- }
1192
- }
1193
- if (middlewareFns.length > 0 || loadingFn) {
1194
- if (loadingFn) {
1195
- showLoadingInContainer(container, loadingFn, match.params);
1196
- }
1197
- Promise.resolve().then(() => {
1198
1684
  try {
1199
- if (middlewareFns.length > 0) {
1200
- runMwChain(0);
1685
+ const result = await fn(ctx, next);
1686
+ if (result && result.redirect) {
1687
+ router.navigate(result.redirect, { replace: true });
1688
+ return;
1201
1689
  }
1202
- else {
1203
- doRenderWithChunks();
1690
+ }
1691
+ catch (e) {
1692
+ if (e && e.name === 'Redirect') {
1693
+ router.navigate(e.url, { replace: true });
1694
+ return;
1204
1695
  }
1205
1696
  }
1697
+ }
1698
+ if (middlewareFns.length > 0 || loadingFn) {
1699
+ if (loadingFn) {
1700
+ showLoadingInContainer(container, loadingFn, match.params);
1701
+ }
1702
+ Promise.resolve().then(() => {
1703
+ try {
1704
+ if (middlewareFns.length > 0) {
1705
+ runMwChain(0);
1706
+ }
1707
+ else {
1708
+ doRenderWithChunks();
1709
+ }
1710
+ }
1711
+ finally {
1712
+ fetchData();
1713
+ }
1714
+ });
1715
+ }
1716
+ else {
1717
+ try {
1718
+ doRenderWithChunks();
1719
+ }
1206
1720
  finally {
1207
1721
  fetchData();
1208
1722
  }
1723
+ }
1724
+ };
1725
+ // Navigation guards — sync fast path for sync guard functions.
1726
+ const guardResult = router._runGuards(url.pathname);
1727
+ if (guardResult instanceof Promise) {
1728
+ return guardResult.then((decision) => {
1729
+ if (decision === false)
1730
+ return;
1731
+ if (typeof decision === 'string' && decision !== url.pathname && (router._guardDepth || 0) < 5) {
1732
+ router._guardDepth = (router._guardDepth || 0) + 1;
1733
+ try {
1734
+ router.navigate(decision, { replace: true });
1735
+ }
1736
+ finally {
1737
+ router._guardDepth = 0;
1738
+ }
1739
+ return;
1740
+ }
1741
+ afterGuards();
1209
1742
  });
1210
1743
  }
1211
- else {
1744
+ if (guardResult === false)
1745
+ return;
1746
+ if (typeof guardResult === 'string' && guardResult !== url.pathname && (router._guardDepth || 0) < 5) {
1747
+ router._guardDepth = (router._guardDepth || 0) + 1;
1212
1748
  try {
1213
- doRenderWithChunks();
1749
+ router.navigate(guardResult, { replace: true });
1214
1750
  }
1215
1751
  finally {
1216
- fetchData();
1752
+ router._guardDepth = 0;
1217
1753
  }
1754
+ return;
1218
1755
  }
1756
+ afterGuards();
1219
1757
  },
1220
1758
  prefetch(path) {
1221
1759
  const url = new URL(path, window.location.origin);
@@ -1237,6 +1775,10 @@ export function createFileRouter(routeTree, options = {}) {
1237
1775
  get currentPath() {
1238
1776
  return get(_state.path);
1239
1777
  },
1778
+ get isLoading() {
1779
+ const cells = getLoadingState();
1780
+ return Boolean(get(cells.isLoading));
1781
+ },
1240
1782
  hmrUpdate() {
1241
1783
  const updated = globalThis.__updatedComponents;
1242
1784
  if (!updated || updated.size === 0)
@@ -1245,43 +1787,15 @@ export function createFileRouter(routeTree, options = {}) {
1245
1787
  if (typeof router.__updateComponents === 'function') {
1246
1788
  router.__updateComponents(router.routeTree);
1247
1789
  }
1248
- const instances = router.__componentInstances;
1249
- if (instances && instances.size > 0) {
1250
- let didUpdate = false;
1251
- for (const [name, nameInstances] of instances) {
1252
- if (updated.has(name)) {
1253
- for (const inst of nameInstances) {
1254
- try {
1255
- const isPage = inst.type === 'page';
1256
- const newFn = isPage ? inst.node.page : inst.node.layout;
1257
- if (!newFn)
1258
- continue;
1259
- const walker = createHydrateWalker(document.createDocumentFragment(), []);
1260
- let newDom;
1261
- root(() => {
1262
- newDom = newFn(inst.props, new Map(), walker);
1263
- });
1264
- if (newDom && newDom.nodeType === 1 && inst.root && inst.root.parentNode) {
1265
- inst.root.parentNode.replaceChild(newDom, inst.root);
1266
- inst.root = newDom;
1267
- didUpdate = true;
1268
- }
1269
- }
1270
- catch (e) {
1271
- console.error('HMR update error:', e);
1272
- }
1273
- }
1274
- }
1275
- }
1276
- if (!didUpdate) {
1277
- const path = window.location.pathname + window.location.search;
1278
- router.navigate(path, { replace: true });
1790
+ const path = window.location.pathname + window.location.search;
1791
+ const match = matchRoute(router.routeTree, path);
1792
+ if (match) {
1793
+ match.pathname = path;
1794
+ const result = renderMatch(router, match, container);
1795
+ if (result && typeof result.then === 'function') {
1796
+ result.catch((e) => console.error('HMR renderMatch error:', e));
1279
1797
  }
1280
1798
  }
1281
- else {
1282
- const path = window.location.pathname + window.location.search;
1283
- router.navigate(path, { replace: true });
1284
- }
1285
1799
  },
1286
1800
  };
1287
1801
  return router;
@@ -1306,6 +1820,8 @@ export function buildRouteTree(definitions) {
1306
1820
  loading: def.loading || null,
1307
1821
  error: def.error || null,
1308
1822
  notFound: def.notFound || null,
1823
+ offline: def.offline ?? null,
1824
+ network: def.network ?? null,
1309
1825
  children: (def.children || []).map(c => {
1310
1826
  const cParts = (c.path || '').split('/').filter(Boolean);
1311
1827
  return {
@@ -1318,11 +1834,11 @@ export function buildRouteTree(definitions) {
1318
1834
  loading: null,
1319
1835
  error: null,
1320
1836
  notFound: null,
1321
- segmentCount: Math.max(1, cParts.length),
1837
+ segmentCount: cParts.length || 1,
1322
1838
  children: [],
1323
1839
  };
1324
1840
  }),
1325
- segmentCount: Math.max(1, parts.length),
1841
+ segmentCount: parts.length,
1326
1842
  };
1327
1843
  tree.push(node);
1328
1844
  }