@vesk/runtime 0.1.14 → 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.
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();
@@ -59,6 +61,17 @@ function hasPendingChunks(nodes) {
59
61
  nodes.forEach(walk);
60
62
  return urls;
61
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
+ }
62
75
  const _dataPromises = new Map();
63
76
  async function fetchRouteData(path) {
64
77
  try {
@@ -94,7 +107,13 @@ async function fetchRouteData(path) {
94
107
  return null;
95
108
  return (await res.json());
96
109
  }
97
- 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 };
98
117
  return null;
99
118
  }
100
119
  }
@@ -140,7 +159,27 @@ function chunkErrorForNode(node) {
140
159
  return new Error(String(node._chunkError));
141
160
  return undefined;
142
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
+ }
143
181
  async function renderNotFound(router, match, container) {
182
+ clearOfflineFlag(router, container);
144
183
  const chain = match.matchChain;
145
184
  const paramValues = match.params;
146
185
  const notFoundFn = findNotFoundComponent(chain);
@@ -169,7 +208,142 @@ async function renderNotFound(router, match, container) {
169
208
  container.innerHTML = '<h1>404 — Not Found</h1>';
170
209
  router._currentMatch = match;
171
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
+ }
172
342
  async function applyRouteData(router, match, data, container, render = renderMatch) {
343
+ if (data.offline) {
344
+ await renderOfflinePage(router, match, container);
345
+ return;
346
+ }
173
347
  if (data.notFound) {
174
348
  await renderNotFound(router, match, container);
175
349
  return;
@@ -240,7 +414,7 @@ function shouldFetchData(router, match) {
240
414
  return Date.now() - fetchedAt >= ttl;
241
415
  }
242
416
  function hasRealPageData(data) {
243
- if (data.notFound || data.redirect || data.error)
417
+ if (data.offline || data.notFound || data.redirect || data.error)
244
418
  return true;
245
419
  if (data.head && data.head.trim().length > 0)
246
420
  return true;
@@ -350,6 +524,26 @@ function replacePageContent(container, newContent) {
350
524
  }
351
525
  return true;
352
526
  }
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
+ }
353
547
  function renderMatch(router, match, container, navToken) {
354
548
  const chain = match.matchChain;
355
549
  const paramValues = match.params;
@@ -390,11 +584,25 @@ function renderMatch(router, match, container, navToken) {
390
584
  return;
391
585
  }
392
586
  // The route matched and has a page name, but its component chunk failed
393
- // to load or compile. Render the route's error boundary (or a generic
394
- // error page) without touching any other route's components.
395
- if (!pageNode.page && pageNode._pageName) {
396
- const chunkErr = chunkErrorForNode(pageNode);
397
- 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
+ });
398
606
  }
399
607
  const layoutNodes = chain.filter(n => n.layout);
400
608
  const tempRoot = document.createDocumentFragment();
@@ -445,7 +653,10 @@ function renderMatch(router, match, container, navToken) {
445
653
  const pageStep = () => {
446
654
  set(_state.params, paramValues);
447
655
  set(_state.path, match.pathname || window.location.pathname);
448
- set(_state.search, window.location.search || '');
656
+ {
657
+ const s = window.location.search || '';
658
+ set(_state.search, s.startsWith('?') ? s.slice(1) : s);
659
+ }
449
660
  const pageProps = { params: paramValues, ...pageNode.props };
450
661
  const result = pageNode.page(pageProps, new Map(), clientWalker);
451
662
  if (result && typeof result.then === 'function') {
@@ -468,7 +679,7 @@ function renderMatch(router, match, container, navToken) {
468
679
  router.navigate(window.location.pathname, { replace: true });
469
680
  }
470
681
  };
471
- 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() };
472
683
  return runInBlockWindow(() => renderLayoutWith(0, () => {
473
684
  const result = errorFn(errorProps, new Map(), clientWalker);
474
685
  if (result && typeof result.then === 'function') {
@@ -489,7 +700,7 @@ function renderMatch(router, match, container, navToken) {
489
700
  router.navigate(window.location.pathname, { replace: true });
490
701
  }
491
702
  };
492
- 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() };
493
704
  const errDom = runInBlockWindow(() => errorFn(errorProps, new Map(), clientWalker));
494
705
  if (errDom && typeof errDom.then === 'function') {
495
706
  errDom.then(mountDom);
@@ -498,26 +709,6 @@ function renderMatch(router, match, container, navToken) {
498
709
  mountDom(errDom);
499
710
  }
500
711
  }
501
- function storePageInstance(router, pageNode, pageProps, result) {
502
- if (!router || !pageNode._pageName || !result || result.nodeType !== 1)
503
- return;
504
- if (!router.__componentInstances)
505
- router.__componentInstances = new Map();
506
- const name = pageNode._pageName;
507
- if (!router.__componentInstances.has(name))
508
- router.__componentInstances.set(name, []);
509
- router.__componentInstances.get(name).push({ root: result, props: pageProps, node: pageNode, type: 'page' });
510
- }
511
- function storeLayoutInstance(router, node, layoutProps, result) {
512
- if (!router || !node._layoutName || !result || result.nodeType !== 1)
513
- return;
514
- if (!router.__componentInstances)
515
- router.__componentInstances = new Map();
516
- const name = node._layoutName;
517
- if (!router.__componentInstances.has(name))
518
- router.__componentInstances.set(name, []);
519
- router.__componentInstances.get(name).push({ root: result, props: layoutProps, node, type: 'layout' });
520
- }
521
712
  const mountDom = (dom) => {
522
713
  // Staleness guard: a suspended (async) page whose render resolves after
523
714
  // a NEWER navigation must not clobber the newer page's DOM. The file
@@ -584,7 +775,13 @@ function makeSsrError(message) {
584
775
  // failure or when the SSR output is already an error page). The error
585
776
  // component renders in place of the page inside the layout chain so the error
586
777
  // page keeps the site nav.
778
+ function clearOfflineFlag(router, container) {
779
+ router._showingOffline = false;
780
+ if (container)
781
+ container.__veskNetworkUI = false;
782
+ }
587
783
  async function renderErrorPage(router, match, container, error) {
784
+ clearOfflineFlag(router, container);
588
785
  const chain = match.matchChain;
589
786
  const paramValues = match.params;
590
787
  const errorFn = findErrorComponent(chain);
@@ -604,7 +801,7 @@ async function renderErrorPage(router, match, container, error) {
604
801
  router.navigate(window.location.pathname, { replace: true });
605
802
  }
606
803
  };
607
- 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() };
608
805
  const renderErrorChain = (index) => {
609
806
  if (index >= layoutNodes.length) {
610
807
  const result = errorFn(errorProps, new Map(), walker);
@@ -678,7 +875,10 @@ async function hydrateInitial(router, match, container, strategy) {
678
875
  })();
679
876
  set(_state.params, paramValues);
680
877
  set(_state.path, match.pathname || window.location.pathname);
681
- set(_state.search, window.location.search || '');
878
+ {
879
+ const s = window.location.search || '';
880
+ set(_state.search, s.startsWith('?') ? s.slice(1) : s);
881
+ }
682
882
  if (ssrErrorMarker !== null) {
683
883
  let message = 'Internal Server Error';
684
884
  if (ssrErrorMarker.startsWith(':')) {
@@ -695,8 +895,13 @@ async function hydrateInitial(router, match, container, strategy) {
695
895
  const layoutNodes = chain.filter(n => n.layout);
696
896
  const hydrators = router.__hydrators;
697
897
  if (!pageNode.page && !(hydrators && pageNode._pageName && hydrators[pageNode._pageName])) {
698
- // The route's component chunk failed to load or compile. Render the
699
- // 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
+ }
700
905
  const chunkErr = chunkErrorForNode(pageNode);
701
906
  await renderErrorPage(router, match, container, chunkErr || new Error(`Component "${pageNode._pageName}" is unavailable`));
702
907
  return;
@@ -716,7 +921,10 @@ async function hydrateInitial(router, match, container, strategy) {
716
921
  if (!strategy || strategy === 'full') {
717
922
  const walker = createHydrateWalker(container);
718
923
  setIsHydrating(true);
719
- 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
+ });
720
928
  setIsHydrating(false);
721
929
  }
722
930
  else if (strategy === 'viewport') {
@@ -741,7 +949,10 @@ async function hydrateInitial(router, match, container, strategy) {
741
949
  if (index >= layoutNodes.length) {
742
950
  return (subWalker) => {
743
951
  if (!strategy || strategy === 'full') {
744
- 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;
745
956
  }
746
957
  else if (strategy === 'viewport') {
747
958
  hydrateViewport(subWalker.root, hydPage, { params: paramValues, ...pageNode.props });
@@ -759,7 +970,9 @@ async function hydrateInitial(router, match, container, strategy) {
759
970
  const hydLayout = hydLayouts[index];
760
971
  const childHydrator = renderLayoutChain(index + 1);
761
972
  const layoutProps = { children: childHydrator, params: paramValues };
762
- return hydLayout(layoutProps, new Map(), walker);
973
+ const result = hydLayout(layoutProps, new Map(), walker);
974
+ storeLayoutInstance(router, node, layoutProps, result);
975
+ return result;
763
976
  }
764
977
  const walker = createHydrateWalker(container);
765
978
  await runInBlockWindow(() => renderLayoutChain(0));
@@ -782,6 +995,20 @@ async function hydrateInitial(router, match, container, strategy) {
782
995
  }
783
996
  }
784
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
+ }
785
1012
  export function createRouter(routes, options = {}) {
786
1013
  const container = options.container || document.getElementById('root');
787
1014
  const prefetch = options.prefetch !== false;
@@ -795,6 +1022,51 @@ export function createRouter(routes, options = {}) {
795
1022
  _currentSegments: null,
796
1023
  _depth: 0,
797
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
+ },
798
1070
  start() {
799
1071
  setCurrentRouter(this);
800
1072
  if (typeof window !== 'undefined' && 'scrollRestoration' in window.history) {
@@ -813,24 +1085,52 @@ export function createRouter(routes, options = {}) {
813
1085
  }, 100);
814
1086
  }, { passive: true });
815
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
+ });
816
1099
  document.addEventListener('click', (e) => {
817
- const link = e.target?.nodeType === 1 ? e.target.closest('a[href]') : null;
818
- 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)
819
1109
  return;
820
- if (link.hostname && link.hostname !== window.location.hostname)
1110
+ const me = e;
1111
+ if (me.metaKey || me.ctrlKey || me.shiftKey || me.altKey || me.button !== 0)
821
1112
  return;
822
- const href = link.getAttribute('href');
823
- if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:'))
1113
+ if (anchor.target === '_blank')
824
1114
  return;
825
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0)
1115
+ if (anchor.hasAttribute('download'))
826
1116
  return;
1117
+ if (anchor.getAttribute('rel') === 'external')
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
+ }
827
1130
  e.preventDefault();
1131
+ e.stopPropagation();
828
1132
  this.navigate(href);
829
1133
  });
830
- window.addEventListener('popstate', () => {
831
- setIsPopStateNavigation(true);
832
- this.navigate(window.location.href, { replace: true });
833
- });
834
1134
  if (prefetch) {
835
1135
  document.addEventListener('mouseenter', (e) => {
836
1136
  const link = e.target?.nodeType === 1 ? e.target.closest('a[href]') : null;
@@ -864,74 +1164,133 @@ export function createRouter(routes, options = {}) {
864
1164
  return;
865
1165
  }
866
1166
  match.pathname = url.pathname;
867
- if (!_isPopStateNavigation) {
868
- _scrollPositions.set(window.location.pathname, window.scrollY);
869
- }
870
- const loadingFn = findLoadingComponent(match.matchChain);
871
- this._navToken = (this._navToken || 0) + 1;
872
- const navToken = this._navToken;
873
- let firstRenderFailed = false;
874
- const updateUrl = () => {
875
- if (!opts.replace) {
876
- window.history.pushState({ path: url.pathname }, '', url.pathname);
877
- }
878
- else {
879
- window.history.replaceState({ 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);
880
1173
  }
881
- set(_state.path, url.pathname);
882
- set(_state.search, url.search);
883
- };
884
- const renderContent = () => {
885
- try {
886
- 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
+ } });
887
1251
  }
888
- catch (e) {
889
- firstRenderFailed = true;
890
- throw e;
1252
+ else {
1253
+ try {
1254
+ doRender();
1255
+ }
1256
+ finally {
1257
+ fetchData();
1258
+ }
891
1259
  }
892
- this._currentMatch = match;
893
- handleScroll(url.pathname, opts.replace);
894
- };
895
- const doRender = () => {
896
- updateUrl();
897
- renderContent();
898
1260
  };
899
- const fetchData = () => {
900
- if (firstRenderFailed)
901
- return;
902
- if (!shouldFetchData(this, match))
903
- return;
904
- getRouteData(url.pathname + url.search, 'nav' + navToken).then(async (data) => {
905
- if (navToken !== this._navToken)
906
- return;
907
- 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)
908
1267
  return;
909
- if (data.redirect) {
910
- 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
+ }
911
1276
  return;
912
1277
  }
913
- if (!hasRealPageData(data))
914
- return;
915
- await applyRouteData(this, match, data, this.container);
1278
+ proceedNav();
916
1279
  });
917
- };
918
- if (loadingFn) {
919
- showLoadingInContainer(this.container, loadingFn, match.params);
920
- Promise.resolve().then(() => { try {
921
- doRender();
922
- }
923
- finally {
924
- fetchData();
925
- } });
926
1280
  }
927
- 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;
928
1285
  try {
929
- doRender();
1286
+ self.navigate(guardResult, { replace: true });
930
1287
  }
931
1288
  finally {
932
- fetchData();
1289
+ self._guardDepth = 0;
933
1290
  }
1291
+ return;
934
1292
  }
1293
+ proceedNav();
935
1294
  },
936
1295
  prefetch(path) {
937
1296
  const url = new URL(path, window.location.origin);
@@ -951,6 +1310,10 @@ export function createRouter(routes, options = {}) {
951
1310
  get currentPath() {
952
1311
  return get(_state.path);
953
1312
  },
1313
+ get isLoading() {
1314
+ const cells = getLoadingState();
1315
+ return Boolean(get(cells.isLoading));
1316
+ },
954
1317
  hmrUpdate() {
955
1318
  const updated = globalThis.__updatedComponents;
956
1319
  if (!updated || updated.size === 0)
@@ -1014,6 +1377,51 @@ export function createFileRouter(routeTree, options = {}) {
1014
1377
  _currentSegments: null,
1015
1378
  _depth: 0,
1016
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
+ },
1017
1425
  start() {
1018
1426
  setCurrentRouter(this);
1019
1427
  if (typeof window !== 'undefined' && 'scrollRestoration' in window.history) {
@@ -1037,6 +1445,41 @@ export function createFileRouter(routeTree, options = {}) {
1037
1445
  setIsPopStateNavigation(true);
1038
1446
  router.navigate(window.location.pathname + window.location.search, { replace: true });
1039
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
+ });
1040
1483
  if (options.prefetch !== false) {
1041
1484
  document.addEventListener('mouseenter', (e) => {
1042
1485
  const link = e.target?.nodeType === 1 ? e.target.closest('a[href]') : null;
@@ -1092,167 +1535,225 @@ export function createFileRouter(routeTree, options = {}) {
1092
1535
  return;
1093
1536
  }
1094
1537
  match.pathname = url.pathname;
1095
- if (!_isPopStateNavigation) {
1096
- _scrollPositions.set(window.location.pathname, window.scrollY);
1097
- }
1098
- const loadingFn = findLoadingComponent(match.matchChain);
1099
- router._navToken = (router._navToken || 0) + 1;
1100
- navDebug('navigate', url.pathname, 'token=' + router._navToken, 'pendingChunks', hasPendingChunks(match.matchChain));
1101
- const navToken = router._navToken;
1102
- const middlewareFns = Array.isArray(middleware) ? middleware : (middleware ? [middleware] : []);
1103
- let firstRenderFailed = false;
1104
- const updateUrl = () => {
1105
- const fullUrl = url.pathname + url.search;
1106
- if (!opts.replace) {
1107
- window.history.pushState({ path: fullUrl }, '', fullUrl);
1538
+ const afterGuards = () => {
1539
+ if (!_isPopStateNavigation) {
1540
+ _scrollPositions.set(window.location.pathname, window.scrollY);
1108
1541
  }
1109
- else {
1110
- window.history.replaceState({ path: fullUrl }, '', fullUrl);
1111
- }
1112
- set(_state.path, url.pathname);
1113
- set(_state.search, url.search);
1114
- };
1115
- const renderContent = () => {
1116
- // Data isolation: route nodes are shared singletons. When the
1117
- // navigation changes the path a node was last hydrated with, its
1118
- // cached props must not render on the new path (a params-only or
1119
- // failed data payload would otherwise merge stale foreign data
1120
- // into the new page).
1121
- const propsNode = findPageNode(match);
1122
- if (propsNode && propsNode._dataPath !== url.pathname) {
1123
- propsNode.props = undefined;
1124
- }
1125
- try {
1126
- // Stamp this navigation's token on the container: a suspended
1127
- // (async) page whose render resolves after a newer navigation
1128
- // must not clobber the newer page's DOM (see renderMatch's
1129
- // mountDom staleness guard).
1130
- container.__veskMountToken = navToken;
1131
- renderFn(router, match, container, navToken);
1132
- }
1133
- catch (e) {
1134
- firstRenderFailed = true;
1135
- throw e;
1136
- }
1137
- router._currentMatch = match;
1138
- handleScroll(url.pathname, opts.replace);
1139
- };
1140
- const doRender = () => {
1141
- navDebug('doRender', url.pathname, 'token=' + navToken);
1142
- updateUrl();
1143
- renderContent();
1144
- navDebug('painted', url.pathname);
1145
- };
1146
- const fetchData = () => {
1147
- if (firstRenderFailed)
1148
- return;
1149
- navDebug('fetchData?', url.pathname, String(shouldFetchData(router, match)));
1150
- if (!shouldFetchData(router, match))
1151
- return;
1152
- getRouteData(url.pathname + url.search, 'nav' + navToken).then(async (data) => {
1153
- navDebug('data arrived', url.pathname, 'token=' + navToken, 'current=' + router._navToken, 'realProps', JSON.stringify(data && hasRealPageData(data)));
1154
- if (navToken !== router._navToken)
1155
- return;
1156
- 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)
1157
1593
  return;
1158
- if (data.redirect) {
1159
- router.navigate(data.redirect, { replace: true });
1594
+ navDebug('fetchData?', url.pathname, String(shouldFetchData(router, match)));
1595
+ if (!shouldFetchData(router, match)) {
1596
+ loadingFinish();
1597
+ scrollToHash();
1160
1598
  return;
1161
1599
  }
1162
- if (!hasRealPageData(data))
1163
- return;
1164
- const pending = hasPendingChunks(match.matchChain);
1165
- if (pending.length > 0) {
1166
- 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)));
1167
1602
  if (navToken !== router._navToken)
1168
1603
  return;
1169
- if (typeof router.__updateComponents === 'function') {
1170
- router.__updateComponents(match.matchChain);
1604
+ if (!data) {
1605
+ loadingFinish();
1606
+ return;
1171
1607
  }
1172
- }
1173
- await applyRouteData(router, match, data, container, renderFn);
1174
- });
1175
- };
1176
- const pendingChunks = hasPendingChunks(match.matchChain);
1177
- const doRenderWithChunks = pendingChunks.length > 0
1178
- ? () => {
1179
- updateUrl();
1180
- return loadChunksQuietly(pendingChunks).then(() => {
1181
- // A newer navigation (e.g. popstate back while the chunk
1182
- // was still loading) may have superseded this one; don't
1183
- // paint the stale route over the current URL.
1184
- if (navToken !== router._navToken)
1608
+ if (data.redirect) {
1609
+ loadingFinish();
1610
+ router.navigate(data.redirect, { replace: true });
1611
+ return;
1612
+ }
1613
+ if (!hasRealPageData(data)) {
1614
+ loadingFinish();
1185
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
+ : (() => {
1186
1665
  if (typeof router.__updateComponents === 'function') {
1187
1666
  router.__updateComponents(match.matchChain);
1188
1667
  }
1189
- renderContent();
1668
+ doRender();
1190
1669
  });
1191
- }
1192
- : (() => {
1193
- // Chunks already loaded (e.g. by prefetch) — resolve
1194
- // component refs so page/layout properties hold functions
1195
- // rather than string names (code-split __resolveNames only
1196
- // stores _pageName; __updateComponents turns it into a ref).
1197
- if (typeof router.__updateComponents === 'function') {
1198
- router.__updateComponents(match.matchChain);
1670
+ async function runMwChain(index) {
1671
+ if (index >= middlewareFns.length) {
1672
+ await doRenderWithChunks();
1673
+ return;
1199
1674
  }
1200
- doRender();
1201
- });
1202
- async function runMwChain(index) {
1203
- if (index >= middlewareFns.length) {
1204
- await doRenderWithChunks();
1205
- return;
1206
- }
1207
- const fn = middlewareFns[index];
1208
- const ctx = { url: url.pathname, params: match.params, router, locals: {} };
1209
- async function next(rewrite) {
1210
- if (rewrite) {
1211
- match.pathname = rewrite;
1212
- url.pathname = rewrite;
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);
1213
1683
  }
1214
- return runMwChain(index + 1);
1215
- }
1216
- try {
1217
- const result = await fn(ctx, next);
1218
- if (result && result.redirect) {
1219
- router.navigate(result.redirect, { replace: true });
1220
- return;
1684
+ try {
1685
+ const result = await fn(ctx, next);
1686
+ if (result && result.redirect) {
1687
+ router.navigate(result.redirect, { replace: true });
1688
+ return;
1689
+ }
1221
1690
  }
1222
- }
1223
- catch (e) {
1224
- if (e && e.name === 'Redirect') {
1225
- router.navigate(e.url, { replace: true });
1226
- return;
1691
+ catch (e) {
1692
+ if (e && e.name === 'Redirect') {
1693
+ router.navigate(e.url, { replace: true });
1694
+ return;
1695
+ }
1227
1696
  }
1228
1697
  }
1229
- }
1230
- if (middlewareFns.length > 0 || loadingFn) {
1231
- if (loadingFn) {
1232
- showLoadingInContainer(container, loadingFn, match.params);
1233
- }
1234
- Promise.resolve().then(() => {
1235
- try {
1236
- if (middlewareFns.length > 0) {
1237
- runMwChain(0);
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
+ }
1238
1710
  }
1239
- else {
1240
- doRenderWithChunks();
1711
+ finally {
1712
+ fetchData();
1241
1713
  }
1714
+ });
1715
+ }
1716
+ else {
1717
+ try {
1718
+ doRenderWithChunks();
1242
1719
  }
1243
1720
  finally {
1244
1721
  fetchData();
1245
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();
1246
1742
  });
1247
1743
  }
1248
- 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;
1249
1748
  try {
1250
- doRenderWithChunks();
1749
+ router.navigate(guardResult, { replace: true });
1251
1750
  }
1252
1751
  finally {
1253
- fetchData();
1752
+ router._guardDepth = 0;
1254
1753
  }
1754
+ return;
1255
1755
  }
1756
+ afterGuards();
1256
1757
  },
1257
1758
  prefetch(path) {
1258
1759
  const url = new URL(path, window.location.origin);
@@ -1274,6 +1775,10 @@ export function createFileRouter(routeTree, options = {}) {
1274
1775
  get currentPath() {
1275
1776
  return get(_state.path);
1276
1777
  },
1778
+ get isLoading() {
1779
+ const cells = getLoadingState();
1780
+ return Boolean(get(cells.isLoading));
1781
+ },
1277
1782
  hmrUpdate() {
1278
1783
  const updated = globalThis.__updatedComponents;
1279
1784
  if (!updated || updated.size === 0)
@@ -1282,43 +1787,15 @@ export function createFileRouter(routeTree, options = {}) {
1282
1787
  if (typeof router.__updateComponents === 'function') {
1283
1788
  router.__updateComponents(router.routeTree);
1284
1789
  }
1285
- const instances = router.__componentInstances;
1286
- if (instances && instances.size > 0) {
1287
- let didUpdate = false;
1288
- for (const [name, nameInstances] of instances) {
1289
- if (updated.has(name)) {
1290
- for (const inst of nameInstances) {
1291
- try {
1292
- const isPage = inst.type === 'page';
1293
- const newFn = isPage ? inst.node.page : inst.node.layout;
1294
- if (!newFn)
1295
- continue;
1296
- const walker = createHydrateWalker(document.createDocumentFragment(), []);
1297
- let newDom;
1298
- root(() => {
1299
- newDom = newFn(inst.props, new Map(), walker);
1300
- });
1301
- if (newDom && newDom.nodeType === 1 && inst.root && inst.root.parentNode) {
1302
- inst.root.parentNode.replaceChild(newDom, inst.root);
1303
- inst.root = newDom;
1304
- didUpdate = true;
1305
- }
1306
- }
1307
- catch (e) {
1308
- console.error('HMR update error:', e);
1309
- }
1310
- }
1311
- }
1312
- }
1313
- if (!didUpdate) {
1314
- const path = window.location.pathname + window.location.search;
1315
- 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));
1316
1797
  }
1317
1798
  }
1318
- else {
1319
- const path = window.location.pathname + window.location.search;
1320
- router.navigate(path, { replace: true });
1321
- }
1322
1799
  },
1323
1800
  };
1324
1801
  return router;
@@ -1343,6 +1820,8 @@ export function buildRouteTree(definitions) {
1343
1820
  loading: def.loading || null,
1344
1821
  error: def.error || null,
1345
1822
  notFound: def.notFound || null,
1823
+ offline: def.offline ?? null,
1824
+ network: def.network ?? null,
1346
1825
  children: (def.children || []).map(c => {
1347
1826
  const cParts = (c.path || '').split('/').filter(Boolean);
1348
1827
  return {
@@ -1355,11 +1834,11 @@ export function buildRouteTree(definitions) {
1355
1834
  loading: null,
1356
1835
  error: null,
1357
1836
  notFound: null,
1358
- segmentCount: Math.max(1, cParts.length),
1837
+ segmentCount: cParts.length || 1,
1359
1838
  children: [],
1360
1839
  };
1361
1840
  }),
1362
- segmentCount: Math.max(1, parts.length),
1841
+ segmentCount: parts.length,
1363
1842
  };
1364
1843
  tree.push(node);
1365
1844
  }