sibujs 3.4.1 → 4.0.0

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 (98) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +118 -15
  3. package/dist/browser.cjs +410 -101
  4. package/dist/browser.d.cts +50 -5
  5. package/dist/browser.d.ts +50 -5
  6. package/dist/browser.js +8 -6
  7. package/dist/build.cjs +777 -385
  8. package/dist/build.js +15 -15
  9. package/dist/cdn.global.js +11 -7
  10. package/dist/{chunk-WEZ5SP7L.js → chunk-3EKJK5FZ.js} +73 -34
  11. package/dist/chunk-4MFZMLUU.js +147 -0
  12. package/dist/chunk-52XFPGSN.js +123 -0
  13. package/dist/chunk-5INI7D2L.js +194 -0
  14. package/dist/{chunk-5MFF5EPT.js → chunk-6LG2T7LO.js} +5 -5
  15. package/dist/{chunk-BQLVTAUZ.js → chunk-ACBT6UZS.js} +57 -22
  16. package/dist/{chunk-F7HW2NI5.js → chunk-AW6HPJ3L.js} +1 -1
  17. package/dist/chunk-DHDZ7IVN.js +109 -0
  18. package/dist/{chunk-NXR2YFZB.js → chunk-DQVUBVNX.js} +16 -25
  19. package/dist/{chunk-7PNKW3FJ.js → chunk-DR7H6UGM.js} +232 -67
  20. package/dist/{chunk-CV7FAQ2R.js → chunk-E62UADKL.js} +9 -3
  21. package/dist/{chunk-VQZDHAEK.js → chunk-ES2GERY2.js} +1 -1
  22. package/dist/{chunk-FWFEBYQ6.js → chunk-EWEE7NBF.js} +115 -41
  23. package/dist/{chunk-PBZWJQNY.js → chunk-F64ZPCFE.js} +8 -8
  24. package/dist/{chunk-S2VW4NBA.js → chunk-GCSGGA3Q.js} +295 -198
  25. package/dist/{chunk-EX77FXTT.js → chunk-HYCCIYNS.js} +228 -51
  26. package/dist/{chunk-MPE75KOA.js → chunk-NF3LYCQH.js} +259 -124
  27. package/dist/chunk-O3QQ3XOL.js +61 -0
  28. package/dist/{chunk-X4JS367G.js → chunk-PBQEW5VW.js} +14 -10
  29. package/dist/{chunk-QHPUFNHZ.js → chunk-RRIDJQ5C.js} +3 -3
  30. package/dist/{chunk-S3NFJO6L.js → chunk-TIRZCERI.js} +36 -5
  31. package/dist/{chunk-3FFCUIGV.js → chunk-VFHIVEPF.js} +309 -188
  32. package/dist/chunk-VPP2FONR.js +142 -0
  33. package/dist/{chunk-335KJEN2.js → chunk-W55YHPEP.js} +5 -3
  34. package/dist/{chunk-Y22QAKJX.js → chunk-WN6WDFCB.js} +4 -4
  35. package/dist/{chunk-YDWP3ZOU.js → chunk-WOOMUJFT.js} +228 -86
  36. package/dist/{chunk-4I3M326N.js → chunk-Z2CE42DB.js} +19 -25
  37. package/dist/{contracts-ey_Qh8ef.d.ts → contracts-Hg6ITAEF.d.cts} +6 -6
  38. package/dist/{contracts-ey_Qh8ef.d.cts → contracts-Hg6ITAEF.d.ts} +6 -6
  39. package/dist/{customElement-CPfIrbvg.d.cts → customElement-CKLoO63w.d.cts} +73 -11
  40. package/dist/{customElement-CPfIrbvg.d.ts → customElement-CKLoO63w.d.ts} +73 -11
  41. package/dist/data.cjs +483 -205
  42. package/dist/data.d.cts +139 -7
  43. package/dist/data.d.ts +139 -7
  44. package/dist/data.js +9 -9
  45. package/dist/devtools.cjs +222 -87
  46. package/dist/devtools.js +8 -8
  47. package/dist/dispose-4FZ6OFXR.js +19 -0
  48. package/dist/ecosystem.cjs +419 -175
  49. package/dist/ecosystem.js +11 -11
  50. package/dist/extras.cjs +1981 -765
  51. package/dist/extras.d.cts +7 -7
  52. package/dist/extras.d.ts +7 -7
  53. package/dist/extras.js +29 -26
  54. package/dist/index.cjs +784 -385
  55. package/dist/index.d.cts +299 -28
  56. package/dist/index.d.ts +299 -28
  57. package/dist/index.js +33 -18
  58. package/dist/motion.cjs +180 -50
  59. package/dist/motion.d.cts +26 -0
  60. package/dist/motion.d.ts +26 -0
  61. package/dist/motion.js +5 -5
  62. package/dist/patterns.cjs +437 -170
  63. package/dist/patterns.d.cts +60 -13
  64. package/dist/patterns.d.ts +60 -13
  65. package/dist/patterns.js +7 -7
  66. package/dist/performance.cjs +333 -148
  67. package/dist/performance.d.cts +55 -7
  68. package/dist/performance.d.ts +55 -7
  69. package/dist/performance.js +9 -9
  70. package/dist/plugins.cjs +1371 -576
  71. package/dist/plugins.d.cts +227 -9
  72. package/dist/plugins.d.ts +227 -9
  73. package/dist/plugins.js +610 -321
  74. package/dist/{ssr-D62yFwuw.d.cts → ssr-BiPRdZ6n.d.cts} +44 -15
  75. package/dist/{ssr-D62yFwuw.d.ts → ssr-BiPRdZ6n.d.ts} +44 -15
  76. package/dist/{ssr-2PPULEK2.js → ssr-JFP5535P.js} +5 -4
  77. package/dist/ssr.cjs +1104 -443
  78. package/dist/ssr.d.cts +230 -20
  79. package/dist/ssr.d.ts +230 -20
  80. package/dist/ssr.js +15 -12
  81. package/dist/{startup-0Qv6aosO.d.cts → startup-BMpaiMhP.d.cts} +1 -1
  82. package/dist/{startup-0Qv6aosO.d.ts → startup-BMpaiMhP.d.ts} +1 -1
  83. package/dist/testing.cjs +205 -63
  84. package/dist/testing.d.cts +1 -1
  85. package/dist/testing.d.ts +1 -1
  86. package/dist/testing.js +8 -10
  87. package/dist/ui.cjs +640 -199
  88. package/dist/ui.d.cts +2 -2
  89. package/dist/ui.d.ts +2 -2
  90. package/dist/ui.js +10 -10
  91. package/dist/widgets.cjs +207 -82
  92. package/dist/widgets.js +9 -9
  93. package/package.json +12 -7
  94. package/dist/chunk-5VGSK6D2.js +0 -80
  95. package/dist/chunk-6CAJU5TP.js +0 -38
  96. package/dist/chunk-COY6PUD2.js +0 -34
  97. package/dist/chunk-DLBFEAIS.js +0 -96
  98. package/dist/chunk-L3GAGWCC.js +0 -111
package/dist/plugins.js CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  preloadCritical,
19
19
  prerenderRoutes,
20
20
  satisfies
21
- } from "./chunk-F7HW2NI5.js";
21
+ } from "./chunk-AW6HPJ3L.js";
22
22
  import {
23
23
  isUnsafeKey
24
24
  } from "./chunk-H3SRKIYX.js";
@@ -35,78 +35,126 @@ import {
35
35
  } from "./chunk-WWV3SJ3L.js";
36
36
  import {
37
37
  span
38
- } from "./chunk-VQZDHAEK.js";
38
+ } from "./chunk-ES2GERY2.js";
39
39
  import {
40
40
  escapeScriptJson,
41
- isDangerousMetaRefresh,
42
- renderToString
43
- } from "./chunk-EX77FXTT.js";
44
- import "./chunk-NXR2YFZB.js";
45
- import "./chunk-DLBFEAIS.js";
46
- import {
47
- globalSingleton
48
- } from "./chunk-7ZHH77QA.js";
41
+ renderToString,
42
+ serializeHeadEntry
43
+ } from "./chunk-HYCCIYNS.js";
44
+ import "./chunk-DQVUBVNX.js";
45
+ import "./chunk-4MFZMLUU.js";
49
46
  import {
50
47
  isUrlAttribute,
51
- sanitizeCSSValue,
48
+ sanitizeStyleAttribute,
52
49
  sanitizeUrl,
53
50
  stripControlChars
54
- } from "./chunk-L3GAGWCC.js";
51
+ } from "./chunk-5INI7D2L.js";
52
+ import {
53
+ globalSingleton
54
+ } from "./chunk-7ZHH77QA.js";
55
55
  import {
56
56
  effect
57
- } from "./chunk-X4JS367G.js";
58
- import "./chunk-S3NFJO6L.js";
57
+ } from "./chunk-PBQEW5VW.js";
58
+ import {
59
+ getRequestStore
60
+ } from "./chunk-TIRZCERI.js";
59
61
  import {
60
62
  dispose,
61
63
  registerDisposer
62
- } from "./chunk-5VGSK6D2.js";
64
+ } from "./chunk-DHDZ7IVN.js";
63
65
  import {
64
66
  signal
65
- } from "./chunk-335KJEN2.js";
67
+ } from "./chunk-W55YHPEP.js";
66
68
  import {
67
69
  track
68
- } from "./chunk-BQLVTAUZ.js";
69
- import "./chunk-COY6PUD2.js";
70
+ } from "./chunk-ACBT6UZS.js";
71
+ import {
72
+ devWarn,
73
+ isDev
74
+ } from "./chunk-VPP2FONR.js";
70
75
 
71
76
  // src/plugins/i18n.ts
72
77
  var _i18n = globalSingleton(/* @__PURE__ */ Symbol.for("sibujs.i18n.v1"), () => ({
73
78
  locale: signal("en"),
74
- locales: {}
79
+ locales: /* @__PURE__ */ Object.create(null)
75
80
  }));
76
- var [currentLocale, setLocaleInternal] = _i18n.locale;
81
+ var [clientLocale, setClientLocale] = _i18n.locale;
77
82
  var locales = _i18n.locales;
83
+ function dictionaryFor(locale) {
84
+ return Object.hasOwn(locales, locale) ? locales[locale] : void 0;
85
+ }
86
+ function lookup(key) {
87
+ const dictionary = dictionaryFor(getLocale());
88
+ if (dictionary === void 0 || !Object.hasOwn(dictionary, key)) return void 0;
89
+ const message = dictionary[key];
90
+ return typeof message === "string" ? message : void 0;
91
+ }
78
92
  function setLocale(locale) {
79
- setLocaleInternal(locale);
93
+ const request = getRequestStore();
94
+ if (request) {
95
+ request.locale = locale;
96
+ return;
97
+ }
98
+ setClientLocale(locale);
80
99
  }
81
100
  function getLocale() {
82
- return currentLocale();
101
+ const request = getRequestStore();
102
+ if (request) {
103
+ return request.locale ?? clientLocale();
104
+ }
105
+ return clientLocale();
83
106
  }
84
107
  function registerTranslations(locale, messages) {
85
- locales[locale] = { ...locales[locale], ...messages };
108
+ const prepared = { ...messages };
109
+ const merged = Object.assign(/* @__PURE__ */ Object.create(null), dictionaryFor(locale), prepared);
110
+ Object.defineProperty(locales, locale, {
111
+ value: merged,
112
+ writable: true,
113
+ enumerable: true,
114
+ configurable: true
115
+ });
86
116
  }
87
117
  function t(key, params) {
88
- const locale = currentLocale();
89
- const message = locales[locale]?.[key] || key;
118
+ const message = lookup(key) ?? key;
90
119
  return params ? message.replace(/\{(\w+)\}/g, (_, p) => String(params[p] ?? "")) : message;
91
120
  }
92
121
  function Trans(key, params) {
93
122
  return span(() => t(key, params));
94
123
  }
95
124
  function hasTranslation(key) {
96
- const locale = currentLocale();
97
- return key in (locales[locale] || {});
125
+ return lookup(key) !== void 0;
98
126
  }
99
127
  function getAvailableLocales() {
100
128
  return Object.keys(locales);
101
129
  }
102
130
 
103
131
  // src/plugins/router.ts
104
- function isSafeNavigationTarget(path) {
105
- if (path === "") return true;
132
+ function classifyNavigationTarget(path) {
133
+ if (path === "") return "internal";
106
134
  const normalized = stripControlChars(path).replace(/\\/g, "/");
107
- if (normalized.startsWith("//")) return false;
108
- return sanitizeUrl(normalized) !== "";
135
+ if (normalized === "") return "internal";
136
+ if (normalized.startsWith("//")) return "unsafe";
137
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(normalized)) return "internal";
138
+ return sanitizeUrl(normalized) === "" ? "unsafe" : "external";
139
+ }
140
+ function isRouterNavigable(path) {
141
+ return classifyNavigationTarget(path) === "internal";
142
+ }
143
+ function normalizePathname(path) {
144
+ if (!path) return "/";
145
+ const trimmed = path.length > 1 ? path.replace(/\/+$/, "") : path;
146
+ return trimmed || "/";
109
147
  }
148
+ function normalizeQuery(query) {
149
+ const params = new URLSearchParams(query);
150
+ return [...params.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${k}=${v}`).join("&");
151
+ }
152
+ function isPathnameAncestor(target, current) {
153
+ if (target === current) return true;
154
+ if (target === "/") return false;
155
+ return current.startsWith(`${target}/`);
156
+ }
157
+ var ABORT_ROUTER_DESTROYED = "sibu:router-destroyed";
110
158
  var LRUCache = class {
111
159
  constructor(maxSize = 100) {
112
160
  this.cache = /* @__PURE__ */ new Map();
@@ -167,9 +215,9 @@ var NavigationController = class {
167
215
  }
168
216
  }
169
217
  }
170
- abort() {
218
+ abort(reason) {
171
219
  if (this.currentController) {
172
- this.currentController.abort();
220
+ this.currentController.abort(reason);
173
221
  this.currentController = null;
174
222
  }
175
223
  }
@@ -355,6 +403,7 @@ var GuardManager = class {
355
403
  this.timeout = timeout;
356
404
  }
357
405
  async runBeforeEach(to, from, signal2) {
406
+ if (signal2.aborted) throw new Error("Navigation aborted");
358
407
  for (const guard of this.beforeEachGuards) {
359
408
  if (signal2.aborted) throw new Error("Navigation aborted");
360
409
  const result = await this.runGuard(guard, to, from, signal2);
@@ -363,6 +412,7 @@ var GuardManager = class {
363
412
  return true;
364
413
  }
365
414
  async runBeforeResolve(to, from, signal2) {
415
+ if (signal2.aborted) throw new Error("Navigation aborted");
366
416
  for (const guard of this.beforeResolveGuards) {
367
417
  if (signal2.aborted) throw new Error("Navigation aborted");
368
418
  const result = await this.runGuard(guard, to, from, signal2);
@@ -459,13 +509,16 @@ var _ComponentLoader = class _ComponentLoader {
459
509
  constructor(cacheSize = 50, retryDelay = 1e3) {
460
510
  this.errorCache = /* @__PURE__ */ new Map();
461
511
  this.loadingPromises = /* @__PURE__ */ new Map();
512
+ // In-flight *module* resolutions for deferred plans, so concurrent first
513
+ // mounts of a lazy route do not each run the loader arrow.
514
+ this.moduleLoads = /* @__PURE__ */ new Map();
462
515
  // Stable per-route-definition id. Caching by the RESOLVED path (e.g.
463
516
  // /users/123) gave the cache one entry per visited URL, so parameterized
464
517
  // routes thrashed/evicted and the component was reloaded every navigation.
465
518
  // Keying by route-definition identity makes the cache effective again.
466
519
  this.routeKeys = /* @__PURE__ */ new WeakMap();
467
520
  this.keyCounter = 0;
468
- this.componentCache = new LRUCache(cacheSize);
521
+ this.planCache = new LRUCache(cacheSize);
469
522
  this.retryDelay = retryDelay;
470
523
  }
471
524
  keyFor(route2) {
@@ -476,91 +529,233 @@ var _ComponentLoader = class _ComponentLoader {
476
529
  }
477
530
  return key;
478
531
  }
479
- async loadComponent(route2, routePath) {
532
+ /**
533
+ * Resolve the route's **plan** — how to produce an instance — without ever
534
+ * instantiating a component to inspect it.
535
+ *
536
+ * Loading a route component and instantiating one are separate operations.
537
+ * The loader never invokes a user component factory merely to validate its
538
+ * return type; validation happens on the instance that will actually be
539
+ * mounted. (LOAD-001)
540
+ */
541
+ async loadPlan(route2, routePath) {
480
542
  if (!("component" in route2)) {
481
543
  throw new Error(`Route ${routePath} does not have a component`);
482
544
  }
483
545
  const comp = route2.component;
484
546
  const cacheKey = this.keyFor(route2);
485
- const cached = this.componentCache.get(cacheKey);
547
+ const cached = this.planCache.get(cacheKey);
486
548
  if (cached) return cached;
487
549
  const existingPromise = this.loadingPromises.get(cacheKey);
488
550
  if (existingPromise) return existingPromise;
489
- const errorInfo = this.errorCache.get(cacheKey);
490
- if (errorInfo && Date.now() - errorInfo.timestamp < this.retryDelay) {
491
- throw new Error(`Component loading failed recently, retry in ${this.retryDelay}ms`);
492
- }
493
- const loadingPromise = this.doLoadComponent(comp, routePath);
551
+ this.assertNotHot(cacheKey);
552
+ const loadingPromise = this.doLoadPlan(comp, routePath);
494
553
  this.loadingPromises.set(cacheKey, loadingPromise);
495
554
  try {
496
- const component = await loadingPromise;
497
- this.componentCache.set(cacheKey, component);
555
+ const plan = await loadingPromise;
556
+ this.planCache.set(cacheKey, plan);
498
557
  this.errorCache.delete(cacheKey);
499
- return component;
558
+ return plan;
500
559
  } catch (error) {
501
- const currentError = this.errorCache.get(cacheKey) || { timestamp: 0, count: 0 };
502
- if (!this.errorCache.has(cacheKey) && this.errorCache.size >= _ComponentLoader.MAX_ERROR_ENTRIES) {
503
- const oldest = this.errorCache.keys().next().value;
504
- if (oldest !== void 0) this.errorCache.delete(oldest);
505
- }
506
- this.errorCache.set(cacheKey, {
507
- timestamp: Date.now(),
508
- count: currentError.count + 1
509
- });
560
+ this.noteError(cacheKey);
510
561
  throw error;
511
562
  } finally {
512
563
  this.loadingPromises.delete(cacheKey);
513
564
  }
514
565
  }
515
- async doLoadComponent(comp, routePath) {
516
- if (!this.isAsyncComponent(comp)) {
517
- const result = comp();
518
- if (!this.isElement(result)) {
519
- throw new Error(`Component for route "${routePath}" must return Element, got ${typeof result}`);
520
- }
521
- return comp;
566
+ /** Reject early while a recent load failure for this route is still hot. */
567
+ assertNotHot(cacheKey) {
568
+ const errorInfo = this.errorCache.get(cacheKey);
569
+ if (errorInfo && Date.now() - errorInfo.timestamp < this.retryDelay) {
570
+ throw new Error(`Component loading failed recently, retry in ${this.retryDelay}ms`);
522
571
  }
572
+ }
573
+ /**
574
+ * Record a load failure. Keyed by route-definition id, so the map is bounded
575
+ * by the number of route definitions; still capped defensively, evicting the
576
+ * oldest.
577
+ */
578
+ noteError(cacheKey) {
579
+ const currentError = this.errorCache.get(cacheKey) || { timestamp: 0, count: 0 };
580
+ if (!this.errorCache.has(cacheKey) && this.errorCache.size >= _ComponentLoader.MAX_ERROR_ENTRIES) {
581
+ const oldest = this.errorCache.keys().next().value;
582
+ if (oldest !== void 0) this.errorCache.delete(oldest);
583
+ }
584
+ this.errorCache.set(cacheKey, { timestamp: Date.now(), count: currentError.count + 1 });
585
+ }
586
+ /**
587
+ * Classify without instantiating.
588
+ *
589
+ * The `lazy()` brand decides, and nothing else: a branded loader is
590
+ * `deferred`, everything else is a `factory`. No inspection of the function's
591
+ * type or source is involved, so an unbranded factory can never become
592
+ * preload-executable. (LOAD-004)
593
+ *
594
+ * An unbranded factory that happens to resolve to a module — an un-marked
595
+ * `async () => ({ default: Page })` — still works: that is unwrapped during
596
+ * real instantiation, where the produced value is used rather than discarded.
597
+ */
598
+ async doLoadPlan(comp, routePath) {
599
+ if (typeof comp !== "function") {
600
+ throw new Error(`Component for route "${routePath}" must be a function, got ${typeof comp}`);
601
+ }
602
+ if (!this.isDeferredModule(comp)) {
603
+ return { kind: "factory", component: comp };
604
+ }
605
+ return { kind: "deferred", load: comp };
606
+ }
607
+ /**
608
+ * Create **one** real route component instance, validating what will actually
609
+ * be mounted.
610
+ *
611
+ * `route` is used only to upgrade a `deferred` plan once a lazy module has
612
+ * resolved, so the import happens once and later mounts call the module's
613
+ * factory directly.
614
+ */
615
+ async instantiate(plan, route2, routePath) {
616
+ if (plan.kind === "factory") {
617
+ return this.instantiateFactory(plan.component, route2, routePath);
618
+ }
619
+ const factory = await this.resolveModuleFactory(plan, route2, routePath);
620
+ return this.instantiateFactory(factory, route2, routePath);
621
+ }
622
+ /**
623
+ * Resolve a deferred plan's module and cache the factory it exports —
624
+ * **without invoking that factory**.
625
+ *
626
+ * Shared by instantiation and preloading, so a preload racing a navigation to
627
+ * the same route imports the module once. A deferred plan is only ever a
628
+ * module loader (see `isDeferredModule`), so a resolution that is not
629
+ * module-shaped is a configuration error and is reported as one.
630
+ */
631
+ async resolveModuleFactory(plan, route2, routePath) {
632
+ const cacheKey = this.keyFor(route2);
633
+ const cached = this.planCache.get(cacheKey);
634
+ if (cached && cached.kind === "factory") return cached.component;
635
+ this.assertNotHot(cacheKey);
636
+ let pending = this.moduleLoads.get(cacheKey);
637
+ const owner = pending === void 0;
638
+ if (pending === void 0) {
639
+ pending = plan.load();
640
+ this.moduleLoads.set(cacheKey, pending);
641
+ }
642
+ let resolved;
523
643
  try {
524
- const result = await comp();
525
- const component = this.extractComponent(result, routePath);
526
- const testElement = component();
527
- if (!this.isElement(testElement)) {
528
- throw new Error(`Component for route "${routePath}" must return Element, got ${typeof testElement}`);
529
- }
530
- return component;
644
+ resolved = await pending;
531
645
  } catch (error) {
532
- const wrapped = new Error(
533
- `Failed to load component for route "${routePath}": ${error instanceof Error ? error.message : String(error)}`
534
- );
535
- wrapped.cause = error;
536
- throw wrapped;
646
+ this.noteError(cacheKey);
647
+ throw error;
648
+ } finally {
649
+ if (owner) this.moduleLoads.delete(cacheKey);
537
650
  }
651
+ const factory = this.extractFactory(resolved, routePath);
652
+ this.planCache.set(cacheKey, { kind: "factory", component: factory });
653
+ return factory;
538
654
  }
539
- isAsyncComponent(comp) {
540
- return comp[LAZY_MARKER] === true || comp.constructor.name === "AsyncFunction" || typeof comp === "function" && comp.toString().includes("import(");
655
+ /**
656
+ * Invoke a factory exactly once and validate the instance it produced.
657
+ *
658
+ * A factory may turn out to resolve to a *module* rather than an Element —
659
+ * an un-marked `async () => ({ default: Page })` is indistinguishable from a
660
+ * component factory until it runs. That is settled here, on the real
661
+ * invocation, and the plan is upgraded so later mounts skip the extra hop.
662
+ * `depth` bounds that unwrapping; a module whose default is itself a module
663
+ * is a configuration error, not something to chase.
664
+ */
665
+ async instantiateFactory(component, route2, routePath, depth = 0) {
666
+ const result = component();
667
+ const value = this.isThenable(result) ? await result : result;
668
+ if (this.isElement(value)) return value;
669
+ if (depth === 0 && this.isFactoryLike(value)) {
670
+ const factory = this.extractFactory(value, routePath);
671
+ this.planCache.set(this.keyFor(route2), { kind: "factory", component: factory });
672
+ return this.instantiateFactory(factory, route2, routePath, depth + 1);
673
+ }
674
+ throw new Error(`Component for route "${routePath}" must return Element, got ${typeof value}`);
541
675
  }
542
- isElement(value) {
543
- return value instanceof Element;
676
+ /**
677
+ * Something `extractFactory` can turn into a component factory: a bare
678
+ * callable, or a module namespace carrying a callable `default`. Both are
679
+ * long-supported shapes for an async loader's resolution.
680
+ */
681
+ isFactoryLike(value) {
682
+ if (typeof value === "function") return true;
683
+ return typeof value === "object" && value !== null && typeof value.default === "function";
544
684
  }
545
- extractComponent(result, routePath) {
546
- if ("default" in result && typeof result.default === "function") {
685
+ /**
686
+ * Pull a factory out of a resolved lazy module **without calling it**. Only
687
+ * what can be checked without running user code is checked here: that a
688
+ * default export exists and is callable.
689
+ */
690
+ extractFactory(result, routePath) {
691
+ if (typeof result === "function") return result;
692
+ if (typeof result === "object" && result !== null && typeof result.default === "function") {
547
693
  return result.default;
548
694
  }
549
- if (typeof result === "function") {
550
- return result;
551
- }
552
- if (this.isElement(result)) {
553
- return () => result;
554
- }
555
695
  throw new Error(`Invalid component module for route "${routePath}"`);
556
696
  }
697
+ /**
698
+ * Prepare explicitly deferred route code without creating a component
699
+ * instance or any DOM.
700
+ *
701
+ * Only routes explicitly branded as deferred — a loader wrapped in `lazy()` —
702
+ * are resolved during preload. Direct component factories, whether
703
+ * synchronous, `async`, or Promise-returning, are `factory` plans and
704
+ * therefore require no preload work at all.
705
+ *
706
+ * ```text
707
+ * factory plan → preload no-op
708
+ * deferred plan → resolve module → cache factory → no component invocation
709
+ * ```
710
+ *
711
+ * Deferred module resolution caches the component factory the module exports
712
+ * **without invoking it**. (LOAD-004)
713
+ */
714
+ async preloadPlan(route2, routePath) {
715
+ const plan = await this.loadPlan(route2, routePath);
716
+ if (plan.kind === "factory") return;
717
+ await this.resolveModuleFactory(plan, route2, routePath);
718
+ }
719
+ isThenable(value) {
720
+ return (typeof value === "object" || typeof value === "function") && value !== null && typeof value.then === "function";
721
+ }
722
+ /**
723
+ * Is this route component an **explicitly declared module loader**?
724
+ *
725
+ * Preload is the only place SibuJS executes a route function before the
726
+ * router needs an instance, so permission to do that must be explicit. The
727
+ * brand stamped by `lazy()` is the sole authority.
728
+ *
729
+ * Everything the loader once inferred is deliberately *not* a signal:
730
+ *
731
+ * - `constructor.name === "AsyncFunction"` — cannot distinguish
732
+ * `async () => import("./Page")` from `async () => div()`. Treating the
733
+ * latter as a module loader is what made preload invoke user
734
+ * components. (LOAD-003)
735
+ * - `toString().includes("import(")` — source text is a representation, not
736
+ * semantics. An ordinary component with `"import('./x')"` in a string, a
737
+ * template literal, or a comment was executed during preload; and the same
738
+ * text can vanish under a bundler that rewrites dynamic imports into its
739
+ * own chunk-loader calls. Both directions are wrong, and no better regex
740
+ * fixes that — the defect is source inspection itself. (LOAD-004)
741
+ *
742
+ * A brand is metadata: it survives TypeScript, every bundler, and
743
+ * minification, because it is part of runtime behaviour rather than syntax.
744
+ */
745
+ isDeferredModule(comp) {
746
+ return comp[LAZY_MARKER] === true;
747
+ }
748
+ isElement(value) {
749
+ return value instanceof Element;
750
+ }
557
751
  clearErrors() {
558
752
  this.errorCache.clear();
559
753
  }
560
754
  clearCache() {
561
- this.componentCache.clear();
755
+ this.planCache.clear();
562
756
  this.errorCache.clear();
563
757
  this.loadingPromises.clear();
758
+ this.moduleLoads.clear();
564
759
  }
565
760
  };
566
761
  _ComponentLoader.MAX_ERROR_ENTRIES = 256;
@@ -569,6 +764,17 @@ var _SibuRouter = class _SibuRouter {
569
764
  constructor(routes, options = {}) {
570
765
  // Event listeners cleanup
571
766
  this.cleanup = [];
767
+ // Set by destroy(). Lets a navigation still in flight report
768
+ // "router-destroyed" rather than the generic "superseded".
769
+ this.destroyed = false;
770
+ // Monotonic navigation generation. Bumped by every committed navigation that
771
+ // can acquire DOM ownership, and by destroy(). Async work that wants to
772
+ // commit into the DOM captures this first and re-checks before committing:
773
+ // unlike a URL comparison it distinguishes "/b" generation 10 from "/b"
774
+ // generation 12, so an A→B→A round trip can never restore commit permission
775
+ // to superseded work. The router's own initial location resolution is
776
+ // excluded — it establishes the bootstrap route rather than superseding it.
777
+ this.navEpoch = 0;
572
778
  this.options = {
573
779
  mode: "history",
574
780
  base: "",
@@ -607,7 +813,7 @@ var _SibuRouter = class _SibuRouter {
607
813
  }
608
814
  queueMicrotask(() => {
609
815
  if (!this.navigator.isNavigating) {
610
- this.handleLocationChange(true);
816
+ this.handleLocationChange(true, true);
611
817
  }
612
818
  this.isReadySetter(true);
613
819
  });
@@ -622,14 +828,15 @@ var _SibuRouter = class _SibuRouter {
622
828
  matched: []
623
829
  };
624
830
  }
625
- handleLocationChange(skipHistory = true) {
831
+ handleLocationChange(skipHistory = true, initialResolution = false) {
626
832
  const path = this.getCurrentPath();
627
- this.navigate(path, { replace: true, skipHistory }).catch((err) => {
833
+ this.navigate(path, { replace: true, skipHistory, initialResolution }).catch((err) => {
628
834
  console.error("[Router] Error during location change navigation:", err);
629
835
  });
630
836
  }
631
837
  getCurrentPath() {
632
838
  const { mode, base } = this.options;
839
+ if (typeof window === "undefined") return "/";
633
840
  if (mode === "hash") {
634
841
  return window.location.hash.slice(1) || "/";
635
842
  }
@@ -661,15 +868,15 @@ var _SibuRouter = class _SibuRouter {
661
868
  try {
662
869
  await this.navigator.navigate(async (signal2) => {
663
870
  const targetPath = this.resolvePath(to);
664
- if (!isSafeNavigationTarget(targetPath)) {
871
+ if (!isRouterNavigable(targetPath)) {
665
872
  const from2 = this.currentRouteGetter();
666
873
  const toContext2 = this.createRouteContext(targetPath);
667
- throw new NavigationFailureError("aborted", from2, toContext2);
874
+ throw new NavigationFailureError("aborted", from2, toContext2, void 0, "unsafe-target");
668
875
  }
669
876
  const from = this.currentRouteGetter();
670
877
  const toContext = this.createRouteContext(targetPath);
671
878
  if (this.isSameRoute(from, toContext)) {
672
- throw new NavigationFailureError("duplicated", from, toContext);
879
+ throw new NavigationFailureError("duplicated", from, toContext, void 0, "duplicate");
673
880
  }
674
881
  await this.performNavigation(toContext, from, options, signal2);
675
882
  });
@@ -677,30 +884,43 @@ var _SibuRouter = class _SibuRouter {
677
884
  } catch (error) {
678
885
  if (error instanceof NavigationFailureError) {
679
886
  const failure2 = error.toFailure();
680
- return { success: false, type: failure2.type, failure: failure2 };
887
+ return { success: false, type: failure2.type, reason: failure2.reason, failure: failure2 };
681
888
  }
889
+ const isAbort = error instanceof Error && error.message === "Navigation aborted";
890
+ const reason = isAbort ? this.destroyed ? "router-destroyed" : "superseded" : "error";
682
891
  const failure = {
683
892
  type: "aborted",
893
+ reason,
684
894
  from: this.currentRouteGetter(),
685
895
  to: this.createRouteContext(this.resolvePath(to)),
686
896
  error: error instanceof Error ? error : new Error(String(error))
687
897
  };
688
- return { success: false, type: failure.type, failure };
898
+ return { success: false, type: failure.type, reason, failure };
689
899
  }
690
900
  }
691
- async performNavigation(to, from, options, signal2, depth = 0) {
901
+ async performNavigation(to, from, options, signal2, depth = 0, redirectChain = []) {
902
+ const chain = depth === 0 ? [to.path] : redirectChain;
692
903
  if (depth >= _SibuRouter.MAX_REDIRECT_DEPTH) {
693
- throw new NavigationFailureError("aborted", from, to);
904
+ if (isDev()) {
905
+ devWarn(
906
+ `Router: redirect loop detected \u2014 navigation aborted after ${_SibuRouter.MAX_REDIRECT_DEPTH} hops.
907
+ ${chain.map((p) => `"${p}"`).join(" -> ")}`
908
+ );
909
+ }
910
+ throw new NavigationFailureError("aborted", from, to, void 0, "redirect-loop");
694
911
  }
695
912
  const beforeEachResult = await this.guards.runBeforeEach(to, from, signal2);
696
913
  if (beforeEachResult !== true) {
697
914
  if (typeof beforeEachResult === "string") {
698
- if (!isSafeNavigationTarget(beforeEachResult)) {
699
- throw new NavigationFailureError("aborted", from, to);
915
+ if (!isRouterNavigable(beforeEachResult)) {
916
+ throw new NavigationFailureError("aborted", from, to, void 0, "unsafe-target");
700
917
  }
701
- return this.performNavigation(this.createRouteContext(beforeEachResult), from, options, signal2, depth + 1);
918
+ return this.performNavigation(this.createRouteContext(beforeEachResult), from, options, signal2, depth + 1, [
919
+ ...chain,
920
+ beforeEachResult
921
+ ]);
702
922
  }
703
- throw new NavigationFailureError("aborted", from, to);
923
+ throw new NavigationFailureError("aborted", from, to, void 0, "guard");
704
924
  }
705
925
  const match = this.matcher.match(to.path);
706
926
  if (match) {
@@ -711,47 +931,60 @@ var _SibuRouter = class _SibuRouter {
711
931
  for (const guard of guards) {
712
932
  if (signal2.aborted) throw new Error("Navigation aborted");
713
933
  const result = await guard(to, from);
934
+ if (signal2.aborted) {
935
+ throw new NavigationFailureError("aborted", from, to, void 0, abortReasonOf(signal2));
936
+ }
714
937
  if (result !== true) {
715
938
  if (typeof result === "string") {
716
- if (!isSafeNavigationTarget(result)) {
717
- throw new NavigationFailureError("aborted", from, to);
939
+ if (!isRouterNavigable(result)) {
940
+ throw new NavigationFailureError("aborted", from, to, void 0, "unsafe-target");
718
941
  }
719
- return this.performNavigation(this.createRouteContext(result), from, options, signal2, depth + 1);
942
+ return this.performNavigation(this.createRouteContext(result), from, options, signal2, depth + 1, [
943
+ ...chain,
944
+ result
945
+ ]);
720
946
  }
721
- throw new NavigationFailureError("aborted", from, to);
947
+ throw new NavigationFailureError("aborted", from, to, void 0, "guard");
722
948
  }
723
949
  }
724
950
  }
725
951
  }
726
952
  if ("redirect" in route2) {
727
953
  const redirectPath = typeof route2.redirect === "function" ? route2.redirect(to) : route2.redirect;
728
- if (typeof redirectPath === "string" && /^(https?:)?\/\//i.test(redirectPath)) {
729
- if (typeof console !== "undefined") {
954
+ if (typeof redirectPath === "string" && !isRouterNavigable(redirectPath)) {
955
+ if (classifyNavigationTarget(redirectPath) === "external" && typeof console !== "undefined") {
730
956
  console.error(
731
957
  `[SibuJS Router] Refusing absolute/protocol-relative redirect "${redirectPath}" \u2014 open-redirect risk.`
732
958
  );
733
959
  }
734
- throw new NavigationFailureError("aborted", from, to);
735
- }
736
- if (typeof redirectPath === "string" && !isSafeNavigationTarget(redirectPath)) {
737
- throw new NavigationFailureError("aborted", from, to);
960
+ throw new NavigationFailureError("aborted", from, to, void 0, "unsafe-target");
738
961
  }
739
- return this.performNavigation(this.createRouteContext(redirectPath), from, options, signal2, depth + 1);
962
+ return this.performNavigation(this.createRouteContext(redirectPath), from, options, signal2, depth + 1, [
963
+ ...chain,
964
+ redirectPath
965
+ ]);
740
966
  }
741
967
  }
742
968
  const beforeResolveResult = await this.guards.runBeforeResolve(to, from, signal2);
743
969
  if (beforeResolveResult !== true) {
744
970
  if (typeof beforeResolveResult === "string") {
745
- if (!isSafeNavigationTarget(beforeResolveResult)) {
746
- throw new NavigationFailureError("aborted", from, to);
971
+ if (!isRouterNavigable(beforeResolveResult)) {
972
+ throw new NavigationFailureError("aborted", from, to, void 0, "unsafe-target");
747
973
  }
748
- return this.performNavigation(this.createRouteContext(beforeResolveResult), from, options, signal2, depth + 1);
974
+ return this.performNavigation(this.createRouteContext(beforeResolveResult), from, options, signal2, depth + 1, [
975
+ ...chain,
976
+ beforeResolveResult
977
+ ]);
749
978
  }
750
- throw new NavigationFailureError("aborted", from, to);
979
+ throw new NavigationFailureError("aborted", from, to, void 0, "guard");
980
+ }
981
+ if (signal2.aborted) {
982
+ throw new NavigationFailureError("aborted", from, to, void 0, abortReasonOf(signal2));
751
983
  }
752
984
  if (!options.skipHistory) {
753
985
  this.updateHistory(to, options);
754
986
  }
987
+ if (!options.initialResolution) this.navEpoch++;
755
988
  this.currentRouteSetter(to);
756
989
  this.guards.runAfterEach(to, from);
757
990
  this.handleScrollBehavior(to, from);
@@ -784,25 +1017,71 @@ var _SibuRouter = class _SibuRouter {
784
1017
  }
785
1018
  updateHistory(to, options) {
786
1019
  const fullPath = this.options.base + to.path + (Object.keys(to.query).length ? `?${new URLSearchParams(to.query).toString()}` : "") + (to.hash ? `#${to.hash}` : "");
1020
+ const h = globalThis.history;
1021
+ if (!h) return;
787
1022
  if (options.replace) {
788
- history.replaceState(options.state || {}, "", fullPath);
1023
+ h.replaceState(options.state || {}, "", fullPath);
789
1024
  } else {
790
- history.pushState(options.state || {}, "", fullPath);
1025
+ h.pushState(options.state || {}, "", fullPath);
791
1026
  }
792
1027
  }
1028
+ /**
1029
+ * Apply the configured `scrollBehavior`, if the runtime can actually scroll.
1030
+ *
1031
+ * This runs *after* the route has been committed, so an exception here does
1032
+ * not merely skip scrolling — it propagates out of `navigateInternal` and the
1033
+ * navigation is reported as `success: false` while the route state says it
1034
+ * succeeded. In a DOM-less runtime the bare `requestAnimationFrame` did
1035
+ * exactly that: a legal `scrollBehavior` option made every navigation report
1036
+ * failure under SSR and in memory-router-style tests. (MEM-001)
1037
+ *
1038
+ * Policy A, consistent with the history write in `updateHistory()` (NODE-001):
1039
+ * the route still commits, and only the browser-only side effect is skipped.
1040
+ *
1041
+ * Both primitives are probed, not just `window` — `requestAnimationFrame` and
1042
+ * `scrollTo` can be missing independently (jsdom without a rAF polyfill, a
1043
+ * partial DOM shim). `requestAnimationFrame` is invoked as a method on the
1044
+ * global object rather than through a detached reference, since browsers
1045
+ * reject a bare call with "Illegal invocation".
1046
+ */
793
1047
  handleScrollBehavior(to, from) {
794
- if (this.options.scrollBehavior) {
795
- const scrollTo = this.options.scrollBehavior(to, from, null);
796
- if (scrollTo) {
797
- requestAnimationFrame(() => {
798
- window.scrollTo(scrollTo.x, scrollTo.y);
799
- });
800
- }
1048
+ const scrollBehavior = this.options.scrollBehavior;
1049
+ if (!scrollBehavior) return;
1050
+ const g = globalThis;
1051
+ if (typeof g.requestAnimationFrame !== "function") return;
1052
+ if (typeof window === "undefined" || typeof window.scrollTo !== "function") return;
1053
+ let position;
1054
+ try {
1055
+ position = scrollBehavior(to, from, null);
1056
+ } catch (error) {
1057
+ if (typeof console !== "undefined") console.error("[router] scrollBehavior failed:", error);
1058
+ return;
801
1059
  }
1060
+ if (!position) return;
1061
+ const { x, y } = position;
1062
+ g.requestAnimationFrame(() => {
1063
+ try {
1064
+ window.scrollTo(x, y);
1065
+ } catch (error) {
1066
+ if (typeof console !== "undefined") console.error("[router] scroll failed:", error);
1067
+ }
1068
+ });
802
1069
  }
803
1070
  // Component loading
804
- async loadComponent(route2, routePath) {
805
- return this.loader.loadComponent(route2, routePath);
1071
+ /**
1072
+ * Resolve how this route's component will be produced, without instantiating
1073
+ * anything. Pair with {@link instantiateComponent} at the commit boundary.
1074
+ */
1075
+ async loadPlan(route2, routePath) {
1076
+ return this.loader.loadPlan(route2, routePath);
1077
+ }
1078
+ /** Create exactly one real instance from an already-resolved plan. */
1079
+ async instantiateComponent(plan, route2, routePath) {
1080
+ return this.loader.instantiate(plan, route2, routePath);
1081
+ }
1082
+ /** Resolve a route's module/factory without creating any component instance. */
1083
+ async preloadPlan(route2, routePath) {
1084
+ return this.loader.preloadPlan(route2, routePath);
806
1085
  }
807
1086
  // Guards API
808
1087
  beforeEach(guard) {
@@ -837,12 +1116,18 @@ var _SibuRouter = class _SibuRouter {
837
1116
  get isReady() {
838
1117
  return this.isReadyGetter();
839
1118
  }
1119
+ /** @internal Navigation generation — see `navEpoch`. Not a public API. */
1120
+ get navigationEpoch() {
1121
+ return this.navEpoch;
1122
+ }
840
1123
  get isNavigating() {
841
1124
  return this.navigator.isNavigating;
842
1125
  }
843
1126
  // Cleanup
844
1127
  destroy() {
845
- this.navigator.abort();
1128
+ this.destroyed = true;
1129
+ this.navEpoch++;
1130
+ this.navigator.abort(ABORT_ROUTER_DESTROYED);
846
1131
  for (const fn of this.cleanup) fn();
847
1132
  this.cleanup = [];
848
1133
  this.guards.clear();
@@ -885,10 +1170,11 @@ var _SibuRouter = class _SibuRouter {
885
1170
  _SibuRouter.MAX_REDIRECT_DEPTH = 10;
886
1171
  var SibuRouter = _SibuRouter;
887
1172
  var NavigationFailureError = class extends Error {
888
- constructor(type, from, to, error) {
889
- super(`Navigation ${type}: from ${from.path} to ${to.path}`);
1173
+ constructor(type, from, to, error, reason) {
1174
+ super(`Navigation ${type}${reason ? ` (${reason})` : ""}: from ${from.path} to ${to.path}`);
890
1175
  this.name = "NavigationFailureError";
891
1176
  this.type = type;
1177
+ this.reason = reason;
892
1178
  this.from = from;
893
1179
  this.to = to;
894
1180
  if (error) {
@@ -898,12 +1184,16 @@ var NavigationFailureError = class extends Error {
898
1184
  toFailure() {
899
1185
  return {
900
1186
  type: this.type,
1187
+ reason: this.reason,
901
1188
  from: this.from,
902
1189
  to: this.to,
903
1190
  error: this.cause instanceof Error ? this.cause : void 0
904
1191
  };
905
1192
  }
906
1193
  };
1194
+ function abortReasonOf(signal2) {
1195
+ return signal2.reason === ABORT_ROUTER_DESTROYED ? "router-destroyed" : "superseded";
1196
+ }
907
1197
  var ROUTER_KEY = /* @__PURE__ */ Symbol.for("sibujs.router.v1");
908
1198
  var _routerRef = globalThis[ROUTER_KEY] ?? (globalThis[ROUTER_KEY] = { current: null });
909
1199
  function normalizeRoutes(routes) {
@@ -1007,6 +1297,13 @@ function Route() {
1007
1297
  let errorNode = null;
1008
1298
  let navSeq = 0;
1009
1299
  let currentTopRoute = null;
1300
+ let routeTorn = false;
1301
+ const commitTarget = (seq) => routeTorn || seq !== navSeq ? null : anchor.parentNode;
1302
+ const release = (node) => {
1303
+ if (!node) return;
1304
+ dispose(node);
1305
+ node.parentNode?.removeChild(node);
1306
+ };
1010
1307
  const cleanupNodes = () => {
1011
1308
  [currentNode, loadingNode, errorNode].forEach((node) => {
1012
1309
  if (!node) return;
@@ -1084,7 +1381,7 @@ function Route() {
1084
1381
  anchor.parentNode.insertBefore(errorNode, anchor.nextSibling);
1085
1382
  };
1086
1383
  const update = async () => {
1087
- if (!_routerRef.current) return;
1384
+ if (routeTorn || !_routerRef.current) return;
1088
1385
  const seq = ++navSeq;
1089
1386
  const route2 = _routerRef.current.currentRoute;
1090
1387
  try {
@@ -1113,17 +1410,22 @@ function Route() {
1113
1410
  if (isAsync) {
1114
1411
  showLoading();
1115
1412
  }
1116
- const component = await _routerRef.current.loadComponent(routeDef, route2.path);
1117
- if (seq !== navSeq) return;
1118
- const node = component();
1119
- if (node && anchor.parentNode) {
1413
+ const plan = await _routerRef.current.loadPlan(routeDef, route2.path);
1414
+ if (!commitTarget(seq)) return;
1415
+ const node = await _routerRef.current.instantiateComponent(plan, routeDef, route2.path);
1416
+ const parent = commitTarget(seq);
1417
+ if (!parent) {
1418
+ release(node);
1419
+ return;
1420
+ }
1421
+ if (node) {
1120
1422
  currentTopRoute = routeDef;
1121
1423
  cleanupNodes();
1122
- anchor.parentNode.insertBefore(node, anchor.nextSibling);
1424
+ parent.insertBefore(node, anchor.nextSibling);
1123
1425
  currentNode = node;
1124
1426
  }
1125
1427
  } catch (error) {
1126
- if (seq !== navSeq) return;
1428
+ if (routeTorn || seq !== navSeq) return;
1127
1429
  hideLoading();
1128
1430
  console.error("[Route] Component error:", error);
1129
1431
  showError(error instanceof Error ? error : new Error(String(error)), routeDef);
@@ -1131,6 +1433,7 @@ function Route() {
1131
1433
  }
1132
1434
  } catch (error) {
1133
1435
  if (seq !== navSeq) return;
1436
+ if (routeTorn) return;
1134
1437
  console.error("[Route] Update failed:", error);
1135
1438
  showError(error instanceof Error ? error : new Error(String(error)));
1136
1439
  }
@@ -1147,12 +1450,13 @@ function Route() {
1147
1450
  if (!routeInitialized && anchor.parentNode) wrappedUpdate();
1148
1451
  });
1149
1452
  }
1150
- let routeTorn = false;
1151
1453
  const routeCleanup = () => {
1152
1454
  if (routeTorn) return;
1153
1455
  routeTorn = true;
1456
+ navSeq++;
1154
1457
  routeTeardown();
1155
1458
  cleanupNodes();
1459
+ currentTopRoute = null;
1156
1460
  };
1157
1461
  registerDisposer(anchor, routeCleanup);
1158
1462
  routeCleanups.push(routeCleanup);
@@ -1170,16 +1474,14 @@ function KeepAliveRoute(options) {
1170
1474
  let currentNode = null;
1171
1475
  let currentKey = "";
1172
1476
  let currentCached = false;
1173
- let isUpdating = false;
1174
- let pendingUpdate = false;
1477
+ let updateSeq = 0;
1478
+ let kaTorn = false;
1175
1479
  const update = async () => {
1176
- if (!_routerRef.current) return;
1177
- if (isUpdating) {
1178
- pendingUpdate = true;
1179
- return;
1180
- }
1181
- const route2 = _routerRef.current.currentRoute;
1182
- const match = _routerRef.current["matcher"].match(route2.path);
1480
+ if (kaTorn || !_routerRef.current) return;
1481
+ const seq = ++updateSeq;
1482
+ const router2 = _routerRef.current;
1483
+ const route2 = router2.currentRoute;
1484
+ const match = router2["matcher"].match(route2.path);
1183
1485
  if (!match) return;
1184
1486
  const { route: routeDef } = match;
1185
1487
  if ("redirect" in routeDef) {
@@ -1196,63 +1498,62 @@ function KeepAliveRoute(options) {
1196
1498
  const cacheKey = `${route2.path}${queryStr}${route2.hash ? `#${route2.hash}` : ""}`;
1197
1499
  const shouldCache = !includeNames || routeDef.name != null && includeNames.includes(routeDef.name);
1198
1500
  if (cacheKey === currentKey && currentNode) return;
1199
- isUpdating = true;
1501
+ if (!anchor.parentNode) return;
1502
+ let node = null;
1503
+ let fromCache = false;
1504
+ if (shouldCache && cache.has(cacheKey)) {
1505
+ node = cache.get(cacheKey);
1506
+ fromCache = true;
1507
+ } else {
1508
+ try {
1509
+ const plan = await router2.loadPlan(routeDef, route2.path);
1510
+ if (kaTorn || seq !== updateSeq) return;
1511
+ node = await router2.instantiateComponent(plan, routeDef, route2.path);
1512
+ } catch (error) {
1513
+ if (kaTorn || seq !== updateSeq) return;
1514
+ console.error("[KeepAliveRoute] Component error:", error);
1515
+ return;
1516
+ }
1517
+ if (!node) return;
1518
+ if (kaTorn || seq !== updateSeq) {
1519
+ dispose(node);
1520
+ return;
1521
+ }
1522
+ }
1200
1523
  const parent = anchor.parentNode;
1201
1524
  if (!parent) {
1202
- isUpdating = false;
1525
+ if (!fromCache) dispose(node);
1203
1526
  return;
1204
1527
  }
1205
- try {
1206
- if (currentNode?.parentNode) {
1207
- parent.removeChild(currentNode);
1208
- if (!currentCached) {
1209
- dispose(currentNode);
1210
- }
1211
- }
1212
- if (shouldCache && cache.has(cacheKey)) {
1213
- currentNode = cache.get(cacheKey);
1214
- currentCached = true;
1215
- const idx = lruOrder.indexOf(cacheKey);
1216
- if (idx !== -1) {
1217
- lruOrder.splice(idx, 1);
1218
- }
1219
- lruOrder.push(cacheKey);
1220
- } else {
1221
- const component = await _routerRef.current.loadComponent(routeDef, route2.path);
1222
- const node = component();
1223
- if (!node || route2.path !== _routerRef.current.currentRoute.path) {
1224
- isUpdating = false;
1225
- return;
1528
+ if (currentNode && currentNode !== node) {
1529
+ if (currentNode.parentNode) currentNode.parentNode.removeChild(currentNode);
1530
+ if (!currentCached) dispose(currentNode);
1531
+ }
1532
+ if (fromCache) {
1533
+ const idx = lruOrder.indexOf(cacheKey);
1534
+ if (idx !== -1) lruOrder.splice(idx, 1);
1535
+ lruOrder.push(cacheKey);
1536
+ } else if (shouldCache) {
1537
+ cache.set(cacheKey, node);
1538
+ lruOrder.push(cacheKey);
1539
+ while (lruOrder.length > maxCache) {
1540
+ const evictKey = lruOrder.shift();
1541
+ if (evictKey === cacheKey) {
1542
+ lruOrder.push(evictKey);
1543
+ break;
1226
1544
  }
1227
- currentNode = node;
1228
- currentCached = shouldCache;
1229
- if (shouldCache) {
1230
- cache.set(cacheKey, node);
1231
- lruOrder.push(cacheKey);
1232
- while (lruOrder.length > maxCache) {
1233
- const evictKey = lruOrder.shift();
1234
- const evictNode = cache.get(evictKey);
1235
- if (evictNode) {
1236
- dispose(evictNode);
1237
- if (evictNode.parentNode) evictNode.parentNode.removeChild(evictNode);
1238
- cache.delete(evictKey);
1239
- }
1240
- }
1545
+ const evictNode = cache.get(evictKey);
1546
+ if (evictNode) {
1547
+ dispose(evictNode);
1548
+ if (evictNode.parentNode) evictNode.parentNode.removeChild(evictNode);
1549
+ cache.delete(evictKey);
1241
1550
  }
1242
1551
  }
1243
- currentKey = cacheKey;
1244
- if (currentNode) {
1245
- parent.insertBefore(currentNode, anchor.nextSibling);
1246
- }
1247
- } catch (error) {
1248
- console.error("[KeepAliveRoute] Component error:", error);
1249
- } finally {
1250
- isUpdating = false;
1251
- if (pendingUpdate) {
1252
- pendingUpdate = false;
1253
- update();
1254
- }
1255
1552
  }
1553
+ currentNode = node;
1554
+ currentCached = fromCache || shouldCache;
1555
+ currentKey = cacheKey;
1556
+ parent.insertBefore(node, anchor.nextSibling);
1256
1557
  };
1257
1558
  let initialized = false;
1258
1559
  const wrappedUpdate = async () => {
@@ -1265,10 +1566,10 @@ function KeepAliveRoute(options) {
1265
1566
  if (!initialized && anchor.parentNode) wrappedUpdate();
1266
1567
  });
1267
1568
  }
1268
- let kaTorn = false;
1269
1569
  const kaCleanup = () => {
1270
1570
  if (kaTorn) return;
1271
1571
  kaTorn = true;
1572
+ updateSeq++;
1272
1573
  kaTeardown();
1273
1574
  for (const node of cache.values()) {
1274
1575
  dispose(node);
@@ -1277,7 +1578,10 @@ function KeepAliveRoute(options) {
1277
1578
  cache.clear();
1278
1579
  lruOrder.length = 0;
1279
1580
  if (currentNode?.parentNode) currentNode.parentNode.removeChild(currentNode);
1581
+ if (currentNode && !currentCached) dispose(currentNode);
1280
1582
  currentNode = null;
1583
+ currentKey = "";
1584
+ currentCached = false;
1281
1585
  };
1282
1586
  registerDisposer(anchor, kaCleanup);
1283
1587
  routeCleanups.push(kaCleanup);
@@ -1290,8 +1594,12 @@ function RouterLink(props, children) {
1290
1594
  const baseClass = typeof classAttr === "string" ? classAttr : "";
1291
1595
  const routeGetter = _routerRef.current.routeGetter;
1292
1596
  const rawHref = _routerRef.current["resolvePath"](to);
1293
- const href = isSafeNavigationTarget(rawHref) ? rawHref : "#";
1294
- const hrefPath = href.split("?")[0].split("#")[0];
1597
+ const kind = classifyNavigationTarget(rawHref);
1598
+ const href = kind === "unsafe" ? "#" : rawHref;
1599
+ const [hrefBeforeHash, hrefHash = ""] = href.split("#");
1600
+ const [hrefPathRaw, hrefQueryRaw = ""] = hrefBeforeHash.split("?");
1601
+ const targetPath = normalizePathname(hrefPathRaw);
1602
+ const targetQuery = normalizeQuery(hrefQueryRaw);
1295
1603
  const link = document.createElement("a");
1296
1604
  link.href = href;
1297
1605
  if (target) {
@@ -1307,8 +1615,9 @@ function RouterLink(props, children) {
1307
1615
  const options = _routerRef.current["options"];
1308
1616
  const effectCleanup = effect(() => {
1309
1617
  const route2 = routeGetter();
1310
- const isActive = route2.path.startsWith(hrefPath);
1311
- const isExactActive = route2.path === hrefPath;
1618
+ const currentPath = normalizePathname(route2.path);
1619
+ const isActive = kind === "internal" && isPathnameAncestor(targetPath, currentPath);
1620
+ const isExactActive = kind === "internal" && currentPath === targetPath && normalizeQuery(route2.query) === targetQuery && route2.hash === hrefHash;
1312
1621
  const classes = [];
1313
1622
  if (isActive) {
1314
1623
  if (activeClass) classes.push(activeClass);
@@ -1330,7 +1639,7 @@ function RouterLink(props, children) {
1330
1639
  const safe = sanitizeUrl(str);
1331
1640
  if (safe) link.setAttribute(key, safe);
1332
1641
  } else if (lkey === "style") {
1333
- link.setAttribute(key, sanitizeCSSValue(str));
1642
+ link.setAttribute(key, sanitizeStyleAttribute(str));
1334
1643
  } else {
1335
1644
  link.setAttribute(key, str);
1336
1645
  }
@@ -1350,10 +1659,13 @@ function RouterLink(props, children) {
1350
1659
  });
1351
1660
  }
1352
1661
  const onLinkClick = (e) => {
1353
- if (target || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) {
1662
+ if (e.defaultPrevented || target || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) {
1354
1663
  return;
1355
1664
  }
1665
+ if (link.hasAttribute("download")) return;
1666
+ if (kind === "external") return;
1356
1667
  e.preventDefault();
1668
+ if (kind === "unsafe") return;
1357
1669
  _routerRef.current?.navigate(to, { replace: replace2 }).catch((err) => {
1358
1670
  if (typeof console !== "undefined") console.error("[router] link navigate failed:", err);
1359
1671
  });
@@ -1369,68 +1681,80 @@ function Suspense(props) {
1369
1681
  let currentNode = null;
1370
1682
  let fallbackNode = null;
1371
1683
  let isLoading = false;
1684
+ let generation = 0;
1685
+ let tornDown = false;
1686
+ const release = (node) => {
1687
+ if (!node) return;
1688
+ dispose(node);
1689
+ node.parentNode?.removeChild(node);
1690
+ };
1691
+ const commitTarget = (myGeneration) => tornDown || myGeneration !== generation ? null : anchor.parentNode;
1372
1692
  const cleanupNodes = () => {
1373
- [currentNode, fallbackNode].forEach((node) => {
1374
- if (node?.parentNode) {
1375
- node.parentNode.removeChild(node);
1376
- }
1377
- });
1693
+ release(currentNode);
1694
+ release(fallbackNode);
1378
1695
  currentNode = null;
1379
1696
  fallbackNode = null;
1380
1697
  };
1381
- const showFallback = () => {
1382
- if (fallbackNode || !props.fallback || !anchor.parentNode) return;
1698
+ const showFallback = (myGeneration) => {
1699
+ if (fallbackNode || !props.fallback || tornDown) return;
1700
+ if (!anchor.parentNode) return;
1701
+ let fallback;
1383
1702
  try {
1384
- const fallback = typeof props.fallback === "function" ? props.fallback() : props.fallback;
1385
- if (fallback instanceof HTMLElement) {
1386
- fallbackNode = fallback;
1387
- anchor.parentNode.insertBefore(fallbackNode, anchor.nextSibling);
1388
- }
1703
+ fallback = typeof props.fallback === "function" ? props.fallback() : props.fallback;
1389
1704
  } catch (error) {
1390
1705
  console.error("[Suspense] Fallback error:", error);
1706
+ return;
1391
1707
  }
1392
- };
1393
- const hideFallback = () => {
1394
- if (fallbackNode?.parentNode) {
1395
- fallbackNode.parentNode.removeChild(fallbackNode);
1396
- fallbackNode = null;
1708
+ if (!(fallback instanceof HTMLElement)) return;
1709
+ const parent = commitTarget(myGeneration);
1710
+ if (!parent) {
1711
+ release(fallback);
1712
+ return;
1397
1713
  }
1714
+ fallbackNode = fallback;
1715
+ parent.insertBefore(fallbackNode, anchor.nextSibling);
1398
1716
  };
1399
1717
  const render = async () => {
1400
- if (isLoading) return;
1718
+ if (tornDown || isLoading) return;
1401
1719
  isLoading = true;
1720
+ const myGeneration = ++generation;
1402
1721
  try {
1403
1722
  const result = props.nodes();
1723
+ let element;
1404
1724
  if (result instanceof Promise) {
1405
- showFallback();
1406
- const element = await result;
1407
- if (anchor.parentNode) {
1408
- cleanupNodes();
1409
- anchor.parentNode.insertBefore(element, anchor.nextSibling);
1410
- currentNode = element;
1411
- }
1725
+ showFallback(myGeneration);
1726
+ element = await result;
1412
1727
  } else {
1413
- if (anchor.parentNode) {
1414
- cleanupNodes();
1415
- anchor.parentNode.insertBefore(result, anchor.nextSibling);
1416
- currentNode = result;
1417
- }
1728
+ element = result;
1418
1729
  }
1419
- } catch (error) {
1420
- hideFallback();
1421
- console.error("[Suspense] Nodes error:", error);
1422
- if (anchor.parentNode) {
1423
- const errorElement = document.createElement("div");
1424
- errorElement.className = "suspense-error";
1425
- errorElement.textContent = error instanceof Error ? error.message : "Failed to load";
1730
+ const parent = commitTarget(myGeneration);
1731
+ if (!parent) {
1732
+ release(element);
1426
1733
  cleanupNodes();
1427
- anchor.parentNode.insertBefore(errorElement, anchor.nextSibling);
1428
- currentNode = errorElement;
1734
+ return;
1429
1735
  }
1736
+ cleanupNodes();
1737
+ parent.insertBefore(element, anchor.nextSibling);
1738
+ currentNode = element;
1739
+ } catch (error) {
1740
+ console.error("[Suspense] Nodes error:", error);
1741
+ const parent = commitTarget(myGeneration);
1742
+ cleanupNodes();
1743
+ if (!parent) return;
1744
+ const errorElement = document.createElement("div");
1745
+ errorElement.className = "suspense-error";
1746
+ errorElement.textContent = error instanceof Error ? error.message : "Failed to load";
1747
+ parent.insertBefore(errorElement, anchor.nextSibling);
1748
+ currentNode = errorElement;
1430
1749
  } finally {
1431
1750
  isLoading = false;
1432
1751
  }
1433
1752
  };
1753
+ registerDisposer(anchor, () => {
1754
+ tornDown = true;
1755
+ generation++;
1756
+ cleanupNodes();
1757
+ });
1434
1758
  queueMicrotask(render);
1435
1759
  return anchor;
1436
1760
  }
@@ -1445,12 +1769,15 @@ async function preloadRoute(to) {
1445
1769
  const match = _routerRef.current["matcher"].match(path.split("?")[0].split("#")[0]);
1446
1770
  if (match && "component" in match.route) {
1447
1771
  try {
1448
- await _routerRef.current.loadComponent(match.route, path);
1772
+ await _routerRef.current.preloadPlan(match.route, path);
1449
1773
  } catch (error) {
1450
1774
  console.warn("[Router] Preload failed:", error);
1451
1775
  }
1452
1776
  }
1453
1777
  }
1778
+ function __getNavigationEpoch() {
1779
+ return _routerRef.current?.navigationEpoch ?? -1;
1780
+ }
1454
1781
  function hasRoute(name) {
1455
1782
  if (!_routerRef.current) return false;
1456
1783
  return _routerRef.current["matcher"].findByName(name) !== null;
@@ -1492,6 +1819,7 @@ function Outlet() {
1492
1819
  let currentNode = null;
1493
1820
  let currentChild = null;
1494
1821
  let navSeq = 0;
1822
+ let outletTorn = false;
1495
1823
  const clearCurrent = () => {
1496
1824
  if (currentNode) {
1497
1825
  dispose(currentNode);
@@ -1500,8 +1828,14 @@ function Outlet() {
1500
1828
  }
1501
1829
  currentChild = null;
1502
1830
  };
1831
+ const commitTarget = (seq) => outletTorn || seq !== navSeq ? null : anchor.parentNode;
1832
+ const release = (node) => {
1833
+ if (!node) return;
1834
+ dispose(node);
1835
+ node.parentNode?.removeChild(node);
1836
+ };
1503
1837
  const update = async () => {
1504
- if (!_routerRef.current) return;
1838
+ if (outletTorn || !_routerRef.current) return;
1505
1839
  const seq = ++navSeq;
1506
1840
  const route2 = _routerRef.current.currentRoute;
1507
1841
  if (route2.matched.length < 2) {
@@ -1516,17 +1850,21 @@ function Outlet() {
1516
1850
  if (childRoute === currentChild && currentNode) return;
1517
1851
  try {
1518
1852
  const cacheKey = `${route2.path}\0${childRoute.path}`;
1519
- const component = await _routerRef.current.loadComponent(childRoute, cacheKey);
1520
- if (seq !== navSeq) return;
1521
- const node = component();
1522
- if (node && anchor.parentNode) {
1523
- clearCurrent();
1524
- anchor.parentNode.insertBefore(node, anchor.nextSibling);
1525
- currentNode = node;
1526
- currentChild = childRoute;
1853
+ const plan = await _routerRef.current.loadPlan(childRoute, cacheKey);
1854
+ if (!commitTarget(seq)) return;
1855
+ const node = await _routerRef.current.instantiateComponent(plan, childRoute, cacheKey);
1856
+ const parent = commitTarget(seq);
1857
+ if (!parent) {
1858
+ release(node);
1859
+ return;
1527
1860
  }
1861
+ if (!node) return;
1862
+ clearCurrent();
1863
+ parent.insertBefore(node, anchor.nextSibling);
1864
+ currentNode = node;
1865
+ currentChild = childRoute;
1528
1866
  } catch (error) {
1529
- if (seq !== navSeq) return;
1867
+ if (outletTorn || seq !== navSeq) return;
1530
1868
  console.error("[Outlet] Failed to render child route:", error);
1531
1869
  }
1532
1870
  };
@@ -1536,16 +1874,17 @@ function Outlet() {
1536
1874
  if (anchor.parentNode) update();
1537
1875
  });
1538
1876
  }
1539
- let outletTorn = false;
1540
1877
  const outletCleanup = () => {
1541
1878
  if (outletTorn) return;
1542
1879
  outletTorn = true;
1880
+ navSeq++;
1543
1881
  outletTeardown();
1544
1882
  if (currentNode) {
1545
1883
  dispose(currentNode);
1546
1884
  if (currentNode.parentNode) currentNode.parentNode.removeChild(currentNode);
1547
1885
  currentNode = null;
1548
1886
  }
1887
+ currentChild = null;
1549
1888
  };
1550
1889
  registerDisposer(anchor, outletCleanup);
1551
1890
  routeCleanups.push(outletCleanup);
@@ -1806,16 +2145,10 @@ function renderRouteToString(url, routes, _options) {
1806
2145
  function renderRouteToDocument(url, routes, options) {
1807
2146
  const { html, state } = renderRouteToString(url, routes, options);
1808
2147
  const opts = options || {};
1809
- const metaTags = (opts.meta || []).filter((attrs) => !isDangerousMetaRefresh(attrs)).map((attrs) => {
1810
- const pairs = buildSafeAttrString(attrs);
1811
- return pairs ? `<meta ${pairs} />` : "";
1812
- }).filter(Boolean).join("\n ");
1813
- const linkTags = (opts.links || []).map((attrs) => {
1814
- const pairs = buildSafeAttrString(attrs);
1815
- return pairs ? `<link ${pairs} />` : "";
1816
- }).filter(Boolean).join("\n ");
2148
+ const metaTags = (opts.meta || []).map((attrs) => serializeHeadEntry("meta", attrs)).filter(Boolean).join("\n ");
2149
+ const linkTags = (opts.links || []).map((attrs) => serializeHeadEntry("link", attrs)).filter(Boolean).join("\n ");
1817
2150
  const scriptTags = (opts.scripts || []).map((src) => {
1818
- const safe = sanitizeUrlLocal(String(src));
2151
+ const safe = sanitizeUrl(String(src));
1819
2152
  if (!safe) return "";
1820
2153
  return `<script src="${escapeAttrLocal(safe)}"></script>`;
1821
2154
  }).filter(Boolean).join("\n ");
@@ -1854,20 +2187,23 @@ function hydrateRouter(routes, options) {
1854
2187
  }
1855
2188
  createRouter(routes);
1856
2189
  const container = options?.container || document.getElementById("app");
1857
- if (container && serverState.path) {
1858
- const resolved = resolveServerRoute(serverState.path, routes);
2190
+ if (!container) return;
2191
+ const liveUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
2192
+ const resolved = resolveServerRoute(liveUrl, routes);
2193
+ const bootstrapEpoch = __getNavigationEpoch();
2194
+ import("./ssr-JFP5535P.js").then(async ({ hydrate }) => {
2195
+ const { replaceChildrenSafely } = await import("./dispose-4FZ6OFXR.js");
2196
+ if (__getNavigationEpoch() !== bootstrapEpoch) return;
1859
2197
  if (resolved.component) {
1860
- import("./ssr-2PPULEK2.js").then(({ hydrate }) => {
1861
- if (resolved.component) {
1862
- hydrate(resolved.component, container);
1863
- }
1864
- }).catch((err) => {
1865
- if (typeof console !== "undefined") {
1866
- console.error("[SibuJS routerSSR] failed to load hydrate:", err);
1867
- }
1868
- });
2198
+ hydrate(resolved.component, container);
2199
+ return;
1869
2200
  }
1870
- }
2201
+ replaceChildrenSafely(container);
2202
+ }).catch((err) => {
2203
+ if (typeof console !== "undefined") {
2204
+ console.error("[SibuJS routerSSR] failed to load hydrate:", err);
2205
+ }
2206
+ });
1871
2207
  }
1872
2208
  function createSSRRouter(routes) {
1873
2209
  return {
@@ -1882,54 +2218,6 @@ function createSSRRouter(routes) {
1882
2218
  }
1883
2219
  };
1884
2220
  }
1885
- var SAFE_ATTR_NAME = /^[A-Za-z_:][-A-Za-z0-9_.:]*$/;
1886
- function isSafeAttrName(name) {
1887
- return SAFE_ATTR_NAME.test(name);
1888
- }
1889
- function isEventHandlerAttr(name) {
1890
- if (name.length < 3) return false;
1891
- const lower = name.toLowerCase();
1892
- return lower[0] === "o" && lower[1] === "n" && lower.charCodeAt(2) >= 97 && lower.charCodeAt(2) <= 122;
1893
- }
1894
- var URL_ATTRS = /* @__PURE__ */ new Set([
1895
- "href",
1896
- "src",
1897
- "action",
1898
- "formaction",
1899
- "cite",
1900
- "poster",
1901
- "background",
1902
- "srcset",
1903
- "ping",
1904
- "manifest",
1905
- "data",
1906
- "xlink:href"
1907
- ]);
1908
- function sanitizeUrlLocal(url) {
1909
- const trimmed = url.replace(/[\x00-\x20\x7f-\x9f]+/g, "").trim();
1910
- if (!trimmed) return "";
1911
- const lower = trimmed.toLowerCase();
1912
- if (lower.startsWith("javascript:") || lower.startsWith("data:") || lower.startsWith("vbscript:") || lower.startsWith("blob:")) {
1913
- return "";
1914
- }
1915
- return trimmed;
1916
- }
1917
- function buildSafeAttrString(attrs) {
1918
- const out = [];
1919
- for (const rawKey of Object.keys(attrs)) {
1920
- if (!Object.hasOwn(attrs, rawKey)) continue;
1921
- if (!isSafeAttrName(rawKey)) continue;
1922
- if (isEventHandlerAttr(rawKey)) continue;
1923
- const lowerKey = rawKey.toLowerCase();
1924
- let value = String(attrs[rawKey]);
1925
- if (URL_ATTRS.has(lowerKey)) {
1926
- value = sanitizeUrlLocal(value);
1927
- if (!value) continue;
1928
- }
1929
- out.push(`${rawKey}="${escapeAttrLocal(value)}"`);
1930
- }
1931
- return out.join(" ");
1932
- }
1933
2221
  function escapeHtmlLocal(str) {
1934
2222
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1935
2223
  }
@@ -1945,6 +2233,7 @@ export {
1945
2233
  Suspense,
1946
2234
  Trans,
1947
2235
  VERSION,
2236
+ __getNavigationEpoch,
1948
2237
  __removeRouterPagehideHandler,
1949
2238
  addRoute,
1950
2239
  afterEach,