@bquery/bquery 1.7.0 → 1.8.1

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 (262) hide show
  1. package/README.md +760 -716
  2. package/dist/{a11y-C5QOVvRn.js → a11y-DVBCy09c.js} +3 -3
  3. package/dist/a11y-DVBCy09c.js.map +1 -0
  4. package/dist/a11y.es.mjs +1 -1
  5. package/dist/component/library.d.ts.map +1 -1
  6. package/dist/{component-CuuTijA6.js → component-L3-JfOFz.js} +5 -5
  7. package/dist/component-L3-JfOFz.js.map +1 -0
  8. package/dist/component.es.mjs +1 -1
  9. package/dist/{config-BW35FKuA.js → config-DhT9auRm.js} +1 -1
  10. package/dist/{config-BW35FKuA.js.map → config-DhT9auRm.js.map} +1 -1
  11. package/dist/{constraints-3lV9yyBw.js → constraints-D5RHQLmP.js} +1 -1
  12. package/dist/constraints-D5RHQLmP.js.map +1 -0
  13. package/dist/core/collection.d.ts +86 -0
  14. package/dist/core/collection.d.ts.map +1 -1
  15. package/dist/core/element.d.ts +28 -0
  16. package/dist/core/element.d.ts.map +1 -1
  17. package/dist/core/shared.d.ts +6 -0
  18. package/dist/core/shared.d.ts.map +1 -1
  19. package/dist/core-DdtZHzsS.js +168 -0
  20. package/dist/core-DdtZHzsS.js.map +1 -0
  21. package/dist/{core-Cjl7GUu8.js → core-EMYSLzaT.js} +289 -259
  22. package/dist/core-EMYSLzaT.js.map +1 -0
  23. package/dist/core.es.mjs +48 -47
  24. package/dist/{custom-directives-7wAShnnd.js → custom-directives-Dr4C5lVV.js} +1 -1
  25. package/dist/custom-directives-Dr4C5lVV.js.map +1 -0
  26. package/dist/{devtools-D2fQLhDN.js → devtools-BhB2iDPT.js} +2 -2
  27. package/dist/devtools-BhB2iDPT.js.map +1 -0
  28. package/dist/devtools.es.mjs +1 -1
  29. package/dist/{dnd-B8EgyzaI.js → dnd-NwZBYh4l.js} +1 -1
  30. package/dist/dnd-NwZBYh4l.js.map +1 -0
  31. package/dist/dnd.es.mjs +1 -1
  32. package/dist/{env-NeVmr4Gf.js → env-CTdvLaH2.js} +1 -1
  33. package/dist/env-CTdvLaH2.js.map +1 -0
  34. package/dist/forms/create-form.d.ts.map +1 -1
  35. package/dist/forms/index.d.ts +3 -2
  36. package/dist/forms/index.d.ts.map +1 -1
  37. package/dist/forms/types.d.ts +46 -0
  38. package/dist/forms/types.d.ts.map +1 -1
  39. package/dist/forms/use-field.d.ts +34 -0
  40. package/dist/forms/use-field.d.ts.map +1 -0
  41. package/dist/forms/validators.d.ts +25 -0
  42. package/dist/forms/validators.d.ts.map +1 -1
  43. package/dist/forms-UcRHsYxC.js +227 -0
  44. package/dist/forms-UcRHsYxC.js.map +1 -0
  45. package/dist/forms.es.mjs +14 -12
  46. package/dist/full.d.ts +17 -26
  47. package/dist/full.d.ts.map +1 -1
  48. package/dist/full.es.mjs +206 -181
  49. package/dist/full.iife.js +33 -33
  50. package/dist/full.iife.js.map +1 -1
  51. package/dist/full.umd.js +33 -33
  52. package/dist/full.umd.js.map +1 -1
  53. package/dist/function-Cybd57JV.js +33 -0
  54. package/dist/function-Cybd57JV.js.map +1 -0
  55. package/dist/{i18n-BnnhTFOS.js → i18n-kuF6Ekj6.js} +3 -3
  56. package/dist/i18n-kuF6Ekj6.js.map +1 -0
  57. package/dist/i18n.es.mjs +1 -1
  58. package/dist/index.es.mjs +251 -228
  59. package/dist/media/breakpoints.d.ts.map +1 -1
  60. package/dist/media/types.d.ts +2 -2
  61. package/dist/media/types.d.ts.map +1 -1
  62. package/dist/{media-Di2Ta22s.js → media-i-fB5WxI.js} +3 -3
  63. package/dist/media-i-fB5WxI.js.map +1 -0
  64. package/dist/media.es.mjs +1 -1
  65. package/dist/{motion-qPj_TYGv.js → motion-BJsAuULb.js} +2 -2
  66. package/dist/motion-BJsAuULb.js.map +1 -0
  67. package/dist/motion.es.mjs +1 -1
  68. package/dist/{mount-SM07RUa6.js → mount-B4Y8bk8Z.js} +5 -5
  69. package/dist/mount-B4Y8bk8Z.js.map +1 -0
  70. package/dist/{platform-CPbCprb6.js → platform-Dw2gE3zI.js} +3 -3
  71. package/dist/{platform-CPbCprb6.js.map → platform-Dw2gE3zI.js.map} +1 -1
  72. package/dist/platform.es.mjs +2 -2
  73. package/dist/plugin/registry.d.ts.map +1 -1
  74. package/dist/{plugin-cPoOHFLY.js → plugin-C2WuC8SF.js} +20 -18
  75. package/dist/plugin-C2WuC8SF.js.map +1 -0
  76. package/dist/plugin.es.mjs +1 -1
  77. package/dist/reactive/async-data.d.ts +28 -3
  78. package/dist/reactive/async-data.d.ts.map +1 -1
  79. package/dist/reactive/computed.d.ts +3 -0
  80. package/dist/reactive/computed.d.ts.map +1 -1
  81. package/dist/reactive/effect.d.ts +3 -0
  82. package/dist/reactive/effect.d.ts.map +1 -1
  83. package/dist/reactive/http.d.ts +194 -0
  84. package/dist/reactive/http.d.ts.map +1 -0
  85. package/dist/reactive/index.d.ts +2 -2
  86. package/dist/reactive/index.d.ts.map +1 -1
  87. package/dist/reactive/pagination.d.ts +126 -0
  88. package/dist/reactive/pagination.d.ts.map +1 -0
  89. package/dist/reactive/polling.d.ts +55 -0
  90. package/dist/reactive/polling.d.ts.map +1 -0
  91. package/dist/reactive/readonly.d.ts +20 -1
  92. package/dist/reactive/readonly.d.ts.map +1 -1
  93. package/dist/reactive/rest.d.ts +293 -0
  94. package/dist/reactive/rest.d.ts.map +1 -0
  95. package/dist/reactive/scope.d.ts +140 -0
  96. package/dist/reactive/scope.d.ts.map +1 -0
  97. package/dist/reactive/signal.d.ts +16 -2
  98. package/dist/reactive/signal.d.ts.map +1 -1
  99. package/dist/reactive/to-value.d.ts +57 -0
  100. package/dist/reactive/to-value.d.ts.map +1 -0
  101. package/dist/reactive/websocket.d.ts +285 -0
  102. package/dist/reactive/websocket.d.ts.map +1 -0
  103. package/dist/reactive-DwkhUJfP.js +1148 -0
  104. package/dist/reactive-DwkhUJfP.js.map +1 -0
  105. package/dist/reactive.es.mjs +38 -19
  106. package/dist/{registry-CWf368tT.js → registry-B08iilIh.js} +1 -1
  107. package/dist/{registry-CWf368tT.js.map → registry-B08iilIh.js.map} +1 -1
  108. package/dist/router/constraints.d.ts.map +1 -1
  109. package/dist/router/index.d.ts +1 -1
  110. package/dist/router/index.d.ts.map +1 -1
  111. package/dist/router/router.d.ts.map +1 -1
  112. package/dist/router/state.d.ts +25 -2
  113. package/dist/router/state.d.ts.map +1 -1
  114. package/dist/router-CQikC9Ed.js +492 -0
  115. package/dist/router-CQikC9Ed.js.map +1 -0
  116. package/dist/router.es.mjs +9 -8
  117. package/dist/ssr/hydrate.d.ts.map +1 -1
  118. package/dist/{ssr-B2qd_WBB.js → ssr-_dAcGdzu.js} +4 -4
  119. package/dist/ssr-_dAcGdzu.js.map +1 -0
  120. package/dist/ssr.es.mjs +1 -1
  121. package/dist/store/persisted.d.ts.map +1 -1
  122. package/dist/{store-DWpyH6p5.js → store-Cb3gPRve.js} +7 -7
  123. package/dist/store-Cb3gPRve.js.map +1 -0
  124. package/dist/store.es.mjs +2 -2
  125. package/dist/storybook.es.mjs.map +1 -1
  126. package/dist/{testing-CsqjNUyy.js → testing-C5Sjfsna.js} +8 -8
  127. package/dist/testing-C5Sjfsna.js.map +1 -0
  128. package/dist/testing.es.mjs +1 -1
  129. package/dist/{type-guards-Do9DWgNp.js → type-guards-BMX2c0LP.js} +1 -1
  130. package/dist/{type-guards-Do9DWgNp.js.map → type-guards-BMX2c0LP.js.map} +1 -1
  131. package/dist/untrack-D0fnO5k2.js +36 -0
  132. package/dist/untrack-D0fnO5k2.js.map +1 -0
  133. package/dist/view/custom-directives.d.ts.map +1 -1
  134. package/dist/view.es.mjs +4 -4
  135. package/package.json +177 -177
  136. package/src/a11y/announce.ts +131 -131
  137. package/src/a11y/audit.ts +314 -314
  138. package/src/a11y/index.ts +68 -68
  139. package/src/a11y/media-preferences.ts +255 -255
  140. package/src/a11y/roving-tab-index.ts +164 -164
  141. package/src/a11y/skip-link.ts +255 -255
  142. package/src/a11y/trap-focus.ts +184 -184
  143. package/src/a11y/types.ts +183 -183
  144. package/src/component/component.ts +599 -599
  145. package/src/component/html.ts +153 -153
  146. package/src/component/index.ts +52 -52
  147. package/src/component/library.ts +540 -542
  148. package/src/component/scope.ts +212 -212
  149. package/src/component/types.ts +310 -310
  150. package/src/core/collection.ts +876 -707
  151. package/src/core/element.ts +1015 -981
  152. package/src/core/env.ts +60 -60
  153. package/src/core/index.ts +49 -49
  154. package/src/core/shared.ts +77 -62
  155. package/src/core/utils/index.ts +148 -148
  156. package/src/devtools/devtools.ts +410 -410
  157. package/src/devtools/index.ts +48 -48
  158. package/src/devtools/types.ts +104 -104
  159. package/src/dnd/draggable.ts +296 -296
  160. package/src/dnd/droppable.ts +228 -228
  161. package/src/dnd/index.ts +62 -62
  162. package/src/dnd/sortable.ts +307 -307
  163. package/src/dnd/types.ts +293 -293
  164. package/src/forms/create-form.ts +320 -278
  165. package/src/forms/index.ts +70 -65
  166. package/src/forms/types.ts +203 -154
  167. package/src/forms/use-field.ts +231 -0
  168. package/src/forms/validators.ts +294 -265
  169. package/src/full.ts +554 -480
  170. package/src/i18n/formatting.ts +67 -67
  171. package/src/i18n/i18n.ts +200 -200
  172. package/src/i18n/index.ts +67 -67
  173. package/src/i18n/translate.ts +182 -182
  174. package/src/i18n/types.ts +171 -171
  175. package/src/index.ts +108 -108
  176. package/src/media/battery.ts +116 -116
  177. package/src/media/breakpoints.ts +129 -131
  178. package/src/media/clipboard.ts +80 -80
  179. package/src/media/device-sensors.ts +158 -158
  180. package/src/media/geolocation.ts +119 -119
  181. package/src/media/index.ts +76 -76
  182. package/src/media/media-query.ts +92 -92
  183. package/src/media/network.ts +115 -115
  184. package/src/media/types.ts +177 -177
  185. package/src/media/viewport.ts +84 -84
  186. package/src/motion/index.ts +57 -57
  187. package/src/motion/morph.ts +151 -151
  188. package/src/motion/parallax.ts +120 -120
  189. package/src/motion/reduced-motion.ts +66 -66
  190. package/src/motion/types.ts +271 -271
  191. package/src/motion/typewriter.ts +164 -164
  192. package/src/plugin/index.ts +37 -37
  193. package/src/plugin/registry.ts +284 -269
  194. package/src/plugin/types.ts +137 -137
  195. package/src/reactive/async-data.ts +250 -29
  196. package/src/reactive/computed.ts +144 -130
  197. package/src/reactive/effect.ts +29 -6
  198. package/src/reactive/http.ts +790 -0
  199. package/src/reactive/index.ts +60 -0
  200. package/src/reactive/pagination.ts +317 -0
  201. package/src/reactive/polling.ts +179 -0
  202. package/src/reactive/readonly.ts +52 -8
  203. package/src/reactive/rest.ts +859 -0
  204. package/src/reactive/scope.ts +276 -0
  205. package/src/reactive/signal.ts +61 -1
  206. package/src/reactive/to-value.ts +71 -0
  207. package/src/reactive/websocket.ts +849 -0
  208. package/src/router/bq-link.ts +279 -279
  209. package/src/router/constraints.ts +204 -201
  210. package/src/router/index.ts +49 -49
  211. package/src/router/match.ts +312 -312
  212. package/src/router/path-pattern.ts +52 -52
  213. package/src/router/query.ts +38 -38
  214. package/src/router/router.ts +421 -402
  215. package/src/router/state.ts +51 -3
  216. package/src/router/types.ts +139 -139
  217. package/src/router/use-route.ts +68 -68
  218. package/src/router/utils.ts +157 -157
  219. package/src/security/index.ts +12 -12
  220. package/src/ssr/hydrate.ts +84 -82
  221. package/src/ssr/index.ts +70 -70
  222. package/src/ssr/render.ts +508 -508
  223. package/src/ssr/serialize.ts +296 -296
  224. package/src/ssr/types.ts +81 -81
  225. package/src/store/create-store.ts +467 -467
  226. package/src/store/index.ts +27 -27
  227. package/src/store/persisted.ts +245 -249
  228. package/src/store/types.ts +247 -247
  229. package/src/store/utils.ts +135 -135
  230. package/src/storybook/index.ts +480 -480
  231. package/src/testing/index.ts +42 -42
  232. package/src/testing/testing.ts +593 -593
  233. package/src/testing/types.ts +170 -170
  234. package/src/view/custom-directives.ts +28 -30
  235. package/src/view/evaluate.ts +292 -292
  236. package/src/view/process.ts +108 -108
  237. package/dist/a11y-C5QOVvRn.js.map +0 -1
  238. package/dist/component-CuuTijA6.js.map +0 -1
  239. package/dist/constraints-3lV9yyBw.js.map +0 -1
  240. package/dist/core-Cjl7GUu8.js.map +0 -1
  241. package/dist/core-DnlyjbF2.js +0 -112
  242. package/dist/core-DnlyjbF2.js.map +0 -1
  243. package/dist/custom-directives-7wAShnnd.js.map +0 -1
  244. package/dist/devtools-D2fQLhDN.js.map +0 -1
  245. package/dist/dnd-B8EgyzaI.js.map +0 -1
  246. package/dist/env-NeVmr4Gf.js.map +0 -1
  247. package/dist/forms-C3yovgH9.js +0 -141
  248. package/dist/forms-C3yovgH9.js.map +0 -1
  249. package/dist/i18n-BnnhTFOS.js.map +0 -1
  250. package/dist/media-Di2Ta22s.js.map +0 -1
  251. package/dist/motion-qPj_TYGv.js.map +0 -1
  252. package/dist/mount-SM07RUa6.js.map +0 -1
  253. package/dist/plugin-cPoOHFLY.js.map +0 -1
  254. package/dist/reactive-Cfv0RK6x.js +0 -233
  255. package/dist/reactive-Cfv0RK6x.js.map +0 -1
  256. package/dist/router-BrthaP_z.js +0 -473
  257. package/dist/router-BrthaP_z.js.map +0 -1
  258. package/dist/ssr-B2qd_WBB.js.map +0 -1
  259. package/dist/store-DWpyH6p5.js.map +0 -1
  260. package/dist/testing-CsqjNUyy.js.map +0 -1
  261. package/dist/untrack-DJVQQ2WM.js +0 -33
  262. package/dist/untrack-DJVQQ2WM.js.map +0 -1
@@ -1,402 +1,421 @@
1
- /**
2
- * Router creation and lifecycle management.
3
- * @module bquery/router
4
- */
5
-
6
- import { isPrototypePollutionKey } from '../core/utils/object';
7
- import { createRoute } from './match';
8
- import { currentRoute, getActiveRouter, routeSignal, setActiveRouter } from './state';
9
- import type { NavigationGuard, Route, Router, RouterOptions } from './types';
10
- import { flattenRoutes } from './utils';
11
-
12
- // ============================================================================
13
- // Router Creation
14
- // ============================================================================
15
-
16
- const MAX_SCROLL_POSITION_ENTRIES = 100;
17
-
18
- const sanitizeHistoryState = (state: Record<string, unknown>): Record<string, unknown> => {
19
- const sanitized: Record<string, unknown> = {};
20
-
21
- for (const [key, value] of Object.entries(state)) {
22
- if (isPrototypePollutionKey(key)) continue;
23
- sanitized[key] = value;
24
- }
25
-
26
- return sanitized;
27
- };
28
-
29
- /**
30
- * Creates and initializes a router instance.
31
- *
32
- * @param options - Router configuration
33
- * @returns The router instance
34
- *
35
- * @example
36
- * ```ts
37
- * import { createRouter } from 'bquery/router';
38
- *
39
- * const router = createRouter({
40
- * routes: [
41
- * { path: '/', component: () => import('./pages/Home') },
42
- * { path: '/about', component: () => import('./pages/About') },
43
- * { path: '/user/:id(\\d+)', component: () => import('./pages/User') },
44
- * { path: '/old-page', redirectTo: '/new-page' },
45
- * { path: '*', component: () => import('./pages/NotFound') },
46
- * ],
47
- * base: '/app',
48
- * scrollRestoration: true,
49
- * });
50
- *
51
- * router.beforeEach((to, from) => {
52
- * if (to.path === '/admin' && !isAuthenticated()) {
53
- * return false; // Cancel navigation
54
- * }
55
- * });
56
- * ```
57
- */
58
- export const createRouter = (options: RouterOptions): Router => {
59
- // Clean up any existing router to prevent guard leakage
60
- const existingRouter = getActiveRouter();
61
- if (existingRouter) {
62
- existingRouter.destroy();
63
- }
64
-
65
- const { routes, base = '', hash: useHash = false, scrollRestoration = false } = options;
66
-
67
- // Instance-specific guards and hooks (not shared globally)
68
- const beforeGuards: NavigationGuard[] = [];
69
- const afterHooks: Array<(to: Route, from: Route) => void> = [];
70
-
71
- // Flatten nested routes (base-relative, not including the base path)
72
- const flatRoutes = flattenRoutes(routes);
73
-
74
- // Scroll position storage keyed by history state id
75
- const scrollPositions = new Map<string, { x: number; y: number }>();
76
- let currentScrollKey = '0';
77
- let scrollKeyCounter = 0;
78
- let previousScrollRestoration: History['scrollRestoration'] | null = null;
79
-
80
- // Enable manual scroll restoration if scrollRestoration is configured
81
- if (scrollRestoration && typeof history !== 'undefined' && 'scrollRestoration' in history) {
82
- previousScrollRestoration = history.scrollRestoration;
83
- if (history.scrollRestoration !== 'manual') {
84
- history.scrollRestoration = 'manual';
85
- }
86
-
87
- const state =
88
- history.state && typeof history.state === 'object'
89
- ? (history.state as Record<string, unknown>)
90
- : {};
91
-
92
- if (typeof state.__bqScrollKey !== 'string') {
93
- const currentUrl = useHash
94
- ? window.location.hash || '#/'
95
- : `${window.location.pathname}${window.location.search}${window.location.hash}`;
96
- history.replaceState({ ...state, __bqScrollKey: currentScrollKey }, '', currentUrl);
97
- }
98
- }
99
-
100
- /**
101
- * Generates a unique key for the current history entry.
102
- * @internal
103
- */
104
- const getScrollKey = (): string => {
105
- return (history.state && history.state.__bqScrollKey) || currentScrollKey;
106
- };
107
-
108
- /**
109
- * Generates a unique key for a new history entry.
110
- * @internal
111
- */
112
- const createScrollKey = (): string => `${Date.now()}-${scrollKeyCounter++}`;
113
-
114
- /**
115
- * Saves current scroll position for the current history entry.
116
- * @internal
117
- */
118
- const saveScrollPosition = (key = getScrollKey()): void => {
119
- if (!scrollRestoration) return;
120
- if (scrollPositions.has(key)) {
121
- // Refresh the insertion order so pruning behaves like an LRU cache.
122
- scrollPositions.delete(key);
123
- }
124
- scrollPositions.set(key, { x: window.scrollX, y: window.scrollY });
125
- while (scrollPositions.size > MAX_SCROLL_POSITION_ENTRIES) {
126
- const oldestKey = scrollPositions.keys().next().value as string | undefined;
127
- if (oldestKey === undefined) {
128
- break;
129
- }
130
- scrollPositions.delete(oldestKey);
131
- }
132
- };
133
-
134
- /**
135
- * Restores scroll position for the current history entry.
136
- * @internal
137
- */
138
- const restoreScrollPosition = (key = getScrollKey()): void => {
139
- if (!scrollRestoration) return;
140
- const pos = scrollPositions.get(key);
141
- if (pos) {
142
- window.scrollTo(pos.x, pos.y);
143
- } else {
144
- window.scrollTo(0, 0);
145
- }
146
- };
147
-
148
- /**
149
- * Builds history state for canceled navigations without dropping
150
- * the scroll restoration key for the current entry.
151
- * @internal
152
- */
153
- const getRestoreHistoryState = (): Record<string, unknown> => {
154
- const state =
155
- history.state && typeof history.state === 'object'
156
- ? { ...(history.state as Record<string, unknown>) }
157
- : {};
158
-
159
- if (scrollRestoration) {
160
- state.__bqScrollKey = currentScrollKey;
161
- }
162
-
163
- return state;
164
- };
165
-
166
- /**
167
- * Gets the current path from the URL.
168
- */
169
- const getCurrentPath = (): { pathname: string; search: string; hash: string } => {
170
- if (useHash) {
171
- const hashPath = window.location.hash.slice(1) || '/';
172
- // In hash routing, URL structure is #/path?query#fragment
173
- // Extract hash fragment first (after the second #)
174
- const [pathWithQuery, hashPart = ''] = hashPath.split('#');
175
- // Then extract query from the path
176
- const [pathname, search = ''] = pathWithQuery.split('?');
177
- return {
178
- pathname,
179
- search: search ? `?${search}` : '',
180
- hash: hashPart ? `#${hashPart}` : '',
181
- };
182
- }
183
-
184
- let pathname = window.location.pathname;
185
- if (base && (pathname === base || pathname.startsWith(base + '/'))) {
186
- pathname = pathname.slice(base.length) || '/';
187
- }
188
-
189
- return {
190
- pathname,
191
- search: window.location.search,
192
- hash: window.location.hash,
193
- };
194
- };
195
-
196
- /**
197
- * Updates the route signal with current URL state.
198
- */
199
- const syncRoute = (): void => {
200
- const { pathname, search, hash } = getCurrentPath();
201
- const newRoute = createRoute(pathname, search, hash, flatRoutes);
202
- routeSignal.value = newRoute;
203
- };
204
-
205
- /**
206
- * Performs navigation with guards.
207
- */
208
- const performNavigation = async (
209
- path: string,
210
- method: 'pushState' | 'replaceState',
211
- visitedPaths: Set<string> = new Set()
212
- ): Promise<void> => {
213
- const { pathname, search, hash } = getCurrentPath();
214
- const from = createRoute(pathname, search, hash, flatRoutes);
215
-
216
- // Parse the target path
217
- const url = new URL(path, window.location.origin);
218
- const resolvedPath = `${url.pathname}${url.search}${url.hash}`;
219
- if (visitedPaths.has(resolvedPath)) {
220
- throw new Error(`bQuery router: redirect loop detected for path "${resolvedPath}"`);
221
- }
222
- visitedPaths.add(resolvedPath);
223
- const to = createRoute(url.pathname, url.search, url.hash, flatRoutes);
224
-
225
- // Check for redirectTo on the matched route
226
- if (to.matched?.redirectTo) {
227
- // Navigate to the redirect target instead
228
- await performNavigation(to.matched.redirectTo, method, visitedPaths);
229
- return;
230
- }
231
-
232
- // Run route-level beforeEnter guard
233
- if (to.matched?.beforeEnter) {
234
- const result = await to.matched.beforeEnter(to, from);
235
- if (result === false) {
236
- return; // Cancel navigation
237
- }
238
- }
239
-
240
- // Run beforeEach guards
241
- for (const guard of beforeGuards) {
242
- const result = await guard(to, from);
243
- if (result === false) {
244
- return; // Cancel navigation
245
- }
246
- }
247
-
248
- // Save scroll position before navigation
249
- saveScrollPosition();
250
-
251
- // Update browser history
252
- const existingScrollKey = scrollRestoration ? getScrollKey() : undefined;
253
- const scrollKey =
254
- method === 'replaceState' && existingScrollKey ? existingScrollKey : createScrollKey();
255
- const fullPath = useHash ? `#${path}` : `${base}${path}`;
256
- const baseState =
257
- scrollRestoration && history.state && typeof history.state === 'object'
258
- ? sanitizeHistoryState(history.state as Record<string, unknown>)
259
- : {};
260
- const state = scrollRestoration ? { ...baseState, __bqScrollKey: scrollKey } : {};
261
- history[method](state, '', fullPath);
262
- currentScrollKey = scrollKey;
263
-
264
- // Update route signal
265
- syncRoute();
266
-
267
- // Scroll to top on push navigation
268
- if (scrollRestoration && method === 'pushState') {
269
- window.scrollTo(0, 0);
270
- }
271
-
272
- // Run afterEach hooks
273
- for (const hook of afterHooks) {
274
- hook(routeSignal.value, from);
275
- }
276
- };
277
-
278
- /**
279
- * Handle popstate events (back/forward).
280
- */
281
- const handlePopState = async (event: PopStateEvent): Promise<void> => {
282
- const { pathname, search, hash } = getCurrentPath();
283
- const from = routeSignal.value;
284
- const to = createRoute(pathname, search, hash, flatRoutes);
285
-
286
- // Check for redirectTo on the matched route
287
- if (to.matched?.redirectTo) {
288
- await performNavigation(to.matched.redirectTo, 'replaceState');
289
- return;
290
- }
291
-
292
- // Run route-level beforeEnter guard
293
- if (to.matched?.beforeEnter) {
294
- const result = await to.matched.beforeEnter(to, from);
295
- if (result === false) {
296
- // Restore previous state with full URL (including query/hash)
297
- const queryString = new URLSearchParams(
298
- Object.entries(from.query).flatMap(([key, value]) =>
299
- Array.isArray(value) ? value.map((v) => [key, v]) : [[key, value]]
300
- )
301
- ).toString();
302
- const searchStr = queryString ? `?${queryString}` : '';
303
- const hashStr = from.hash ? `#${from.hash}` : '';
304
- const restorePath = useHash
305
- ? `#${from.path}${searchStr}${hashStr}`
306
- : `${base}${from.path}${searchStr}${hashStr}`;
307
- history.replaceState(getRestoreHistoryState(), '', restorePath);
308
- return;
309
- }
310
- }
311
-
312
- // Run beforeEach guards (supports async guards)
313
- for (const guard of beforeGuards) {
314
- const result = await guard(to, from);
315
- if (result === false) {
316
- // Restore previous state with full URL (including query/hash)
317
- const queryString = new URLSearchParams(
318
- Object.entries(from.query).flatMap(([key, value]) =>
319
- Array.isArray(value) ? value.map((v) => [key, v]) : [[key, value]]
320
- )
321
- ).toString();
322
- const search = queryString ? `?${queryString}` : '';
323
- const hash = from.hash ? `#${from.hash}` : '';
324
- const restorePath = useHash
325
- ? `#${from.path}${search}${hash}`
326
- : `${base}${from.path}${search}${hash}`;
327
- history.replaceState(getRestoreHistoryState(), '', restorePath);
328
- return;
329
- }
330
- }
331
-
332
- // Save scroll position of the page we're leaving
333
- saveScrollPosition(currentScrollKey);
334
-
335
- // Update scroll key from history state
336
- currentScrollKey =
337
- (event.state as { __bqScrollKey?: string } | null)?.__bqScrollKey ?? getScrollKey();
338
-
339
- syncRoute();
340
-
341
- // Restore scroll position for the entry we're navigating to
342
- restoreScrollPosition(currentScrollKey);
343
-
344
- for (const hook of afterHooks) {
345
- hook(routeSignal.value, from);
346
- }
347
- };
348
-
349
- // Attach popstate listener
350
- window.addEventListener('popstate', handlePopState);
351
-
352
- // Initialize route
353
- syncRoute();
354
-
355
- const router: Router = {
356
- push: (path: string) => performNavigation(path, 'pushState'),
357
- replace: (path: string) => performNavigation(path, 'replaceState'),
358
- back: () => history.back(),
359
- forward: () => history.forward(),
360
- go: (delta: number) => history.go(delta),
361
-
362
- beforeEach: (guard: NavigationGuard) => {
363
- beforeGuards.push(guard);
364
- return () => {
365
- const index = beforeGuards.indexOf(guard);
366
- if (index > -1) beforeGuards.splice(index, 1);
367
- };
368
- },
369
-
370
- afterEach: (hook: (to: Route, from: Route) => void) => {
371
- afterHooks.push(hook);
372
- return () => {
373
- const index = afterHooks.indexOf(hook);
374
- if (index > -1) afterHooks.splice(index, 1);
375
- };
376
- },
377
-
378
- currentRoute,
379
- routes: flatRoutes,
380
- base,
381
- hash: useHash,
382
-
383
- destroy: () => {
384
- window.removeEventListener('popstate', handlePopState);
385
- beforeGuards.length = 0;
386
- afterHooks.length = 0;
387
- scrollPositions.clear();
388
- // Restore the previous scroll restoration mode on destroy
389
- if (
390
- previousScrollRestoration !== null &&
391
- typeof history !== 'undefined' &&
392
- 'scrollRestoration' in history
393
- ) {
394
- history.scrollRestoration = previousScrollRestoration;
395
- }
396
- setActiveRouter(null);
397
- },
398
- };
399
-
400
- setActiveRouter(router);
401
- return router;
402
- };
1
+ /**
2
+ * Router creation and lifecycle management.
3
+ * @module bquery/router
4
+ */
5
+
6
+ import { isPrototypePollutionKey } from '../core/utils/object';
7
+ import { createRoute } from './match';
8
+ import {
9
+ beginNavigation,
10
+ currentRoute,
11
+ endNavigation,
12
+ getActiveRouter,
13
+ resetNavigationState,
14
+ routeSignal,
15
+ setActiveRouter,
16
+ } from './state';
17
+ import type { NavigationGuard, Route, Router, RouterOptions } from './types';
18
+ import { flattenRoutes } from './utils';
19
+
20
+ // ============================================================================
21
+ // Router Creation
22
+ // ============================================================================
23
+
24
+ const MAX_SCROLL_POSITION_ENTRIES = 100;
25
+
26
+ const sanitizeHistoryState = (state: Record<string, unknown>): Record<string, unknown> => {
27
+ const sanitized: Record<string, unknown> = {};
28
+
29
+ for (const [key, value] of Object.entries(state)) {
30
+ if (isPrototypePollutionKey(key)) continue;
31
+ sanitized[key] = value;
32
+ }
33
+
34
+ return sanitized;
35
+ };
36
+
37
+ /**
38
+ * Creates and initializes a router instance.
39
+ *
40
+ * @param options - Router configuration
41
+ * @returns The router instance
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * import { createRouter } from 'bquery/router';
46
+ *
47
+ * const router = createRouter({
48
+ * routes: [
49
+ * { path: '/', component: () => import('./pages/Home') },
50
+ * { path: '/about', component: () => import('./pages/About') },
51
+ * { path: '/user/:id(\\d+)', component: () => import('./pages/User') },
52
+ * { path: '/old-page', redirectTo: '/new-page' },
53
+ * { path: '*', component: () => import('./pages/NotFound') },
54
+ * ],
55
+ * base: '/app',
56
+ * scrollRestoration: true,
57
+ * });
58
+ *
59
+ * router.beforeEach((to, from) => {
60
+ * if (to.path === '/admin' && !isAuthenticated()) {
61
+ * return false; // Cancel navigation
62
+ * }
63
+ * });
64
+ * ```
65
+ */
66
+ export const createRouter = (options: RouterOptions): Router => {
67
+ // Clean up any existing router to prevent guard leakage
68
+ const existingRouter = getActiveRouter();
69
+ if (existingRouter) {
70
+ existingRouter.destroy();
71
+ }
72
+
73
+ const { routes, base = '', hash: useHash = false, scrollRestoration = false } = options;
74
+
75
+ // Instance-specific guards and hooks (not shared globally)
76
+ const beforeGuards: NavigationGuard[] = [];
77
+ const afterHooks: Array<(to: Route, from: Route) => void> = [];
78
+
79
+ // Flatten nested routes (base-relative, not including the base path)
80
+ const flatRoutes = flattenRoutes(routes);
81
+
82
+ // Scroll position storage keyed by history state id
83
+ const scrollPositions = new Map<string, { x: number; y: number }>();
84
+ let currentScrollKey = '0';
85
+ let scrollKeyCounter = 0;
86
+ let previousScrollRestoration: History['scrollRestoration'] | null = null;
87
+
88
+ // Enable manual scroll restoration if scrollRestoration is configured
89
+ if (scrollRestoration && typeof history !== 'undefined' && 'scrollRestoration' in history) {
90
+ previousScrollRestoration = history.scrollRestoration;
91
+ if (history.scrollRestoration !== 'manual') {
92
+ history.scrollRestoration = 'manual';
93
+ }
94
+
95
+ const state =
96
+ history.state && typeof history.state === 'object'
97
+ ? (history.state as Record<string, unknown>)
98
+ : {};
99
+
100
+ if (typeof state.__bqScrollKey !== 'string') {
101
+ const currentUrl = useHash
102
+ ? window.location.hash || '#/'
103
+ : `${window.location.pathname}${window.location.search}${window.location.hash}`;
104
+ history.replaceState({ ...state, __bqScrollKey: currentScrollKey }, '', currentUrl);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Generates a unique key for the current history entry.
110
+ * @internal
111
+ */
112
+ const getScrollKey = (): string => {
113
+ return (history.state && history.state.__bqScrollKey) || currentScrollKey;
114
+ };
115
+
116
+ /**
117
+ * Generates a unique key for a new history entry.
118
+ * @internal
119
+ */
120
+ const createScrollKey = (): string => `${Date.now()}-${scrollKeyCounter++}`;
121
+
122
+ /**
123
+ * Saves current scroll position for the current history entry.
124
+ * @internal
125
+ */
126
+ const saveScrollPosition = (key = getScrollKey()): void => {
127
+ if (!scrollRestoration) return;
128
+ if (scrollPositions.has(key)) {
129
+ // Refresh the insertion order so pruning behaves like an LRU cache.
130
+ scrollPositions.delete(key);
131
+ }
132
+ scrollPositions.set(key, { x: window.scrollX, y: window.scrollY });
133
+ while (scrollPositions.size > MAX_SCROLL_POSITION_ENTRIES) {
134
+ const oldestKey = scrollPositions.keys().next().value as string | undefined;
135
+ if (oldestKey === undefined) {
136
+ break;
137
+ }
138
+ scrollPositions.delete(oldestKey);
139
+ }
140
+ };
141
+
142
+ /**
143
+ * Restores scroll position for the current history entry.
144
+ * @internal
145
+ */
146
+ const restoreScrollPosition = (key = getScrollKey()): void => {
147
+ if (!scrollRestoration) return;
148
+ const pos = scrollPositions.get(key);
149
+ if (pos) {
150
+ window.scrollTo(pos.x, pos.y);
151
+ } else {
152
+ window.scrollTo(0, 0);
153
+ }
154
+ };
155
+
156
+ /**
157
+ * Builds history state for canceled navigations without dropping
158
+ * the scroll restoration key for the current entry.
159
+ * @internal
160
+ */
161
+ const getRestoreHistoryState = (): Record<string, unknown> => {
162
+ const state =
163
+ history.state && typeof history.state === 'object'
164
+ ? { ...(history.state as Record<string, unknown>) }
165
+ : {};
166
+
167
+ if (scrollRestoration) {
168
+ state.__bqScrollKey = currentScrollKey;
169
+ }
170
+
171
+ return state;
172
+ };
173
+
174
+ /**
175
+ * Gets the current path from the URL.
176
+ */
177
+ const getCurrentPath = (): { pathname: string; search: string; hash: string } => {
178
+ if (useHash) {
179
+ const hashPath = window.location.hash.slice(1) || '/';
180
+ // In hash routing, URL structure is #/path?query#fragment
181
+ // Extract hash fragment first (after the second #)
182
+ const [pathWithQuery, hashPart = ''] = hashPath.split('#');
183
+ // Then extract query from the path
184
+ const [pathname, search = ''] = pathWithQuery.split('?');
185
+ return {
186
+ pathname,
187
+ search: search ? `?${search}` : '',
188
+ hash: hashPart ? `#${hashPart}` : '',
189
+ };
190
+ }
191
+
192
+ let pathname = window.location.pathname;
193
+ if (base && (pathname === base || pathname.startsWith(base + '/'))) {
194
+ pathname = pathname.slice(base.length) || '/';
195
+ }
196
+
197
+ return {
198
+ pathname,
199
+ search: window.location.search,
200
+ hash: window.location.hash,
201
+ };
202
+ };
203
+
204
+ /**
205
+ * Updates the route signal with current URL state.
206
+ */
207
+ const syncRoute = (): void => {
208
+ const { pathname, search, hash } = getCurrentPath();
209
+ const newRoute = createRoute(pathname, search, hash, flatRoutes);
210
+ routeSignal.value = newRoute;
211
+ };
212
+
213
+ /**
214
+ * Performs navigation with guards.
215
+ */
216
+ const performNavigation = async (
217
+ path: string,
218
+ method: 'pushState' | 'replaceState',
219
+ visitedPaths: Set<string> = new Set()
220
+ ): Promise<void> => {
221
+ beginNavigation();
222
+ try {
223
+ const { pathname, search, hash } = getCurrentPath();
224
+ const from = createRoute(pathname, search, hash, flatRoutes);
225
+
226
+ // Parse the target path
227
+ const url = new URL(path, window.location.origin);
228
+ const resolvedPath = `${url.pathname}${url.search}${url.hash}`;
229
+ if (visitedPaths.has(resolvedPath)) {
230
+ throw new Error(`bQuery router: redirect loop detected for path "${resolvedPath}"`);
231
+ }
232
+ visitedPaths.add(resolvedPath);
233
+ const to = createRoute(url.pathname, url.search, url.hash, flatRoutes);
234
+
235
+ // Check for redirectTo on the matched route
236
+ if (to.matched?.redirectTo) {
237
+ // Navigate to the redirect target instead
238
+ await performNavigation(to.matched.redirectTo, method, visitedPaths);
239
+ return;
240
+ }
241
+
242
+ // Run route-level beforeEnter guard
243
+ if (to.matched?.beforeEnter) {
244
+ const result = await to.matched.beforeEnter(to, from);
245
+ if (result === false) {
246
+ return; // Cancel navigation
247
+ }
248
+ }
249
+
250
+ // Run beforeEach guards
251
+ for (const guard of beforeGuards) {
252
+ const result = await guard(to, from);
253
+ if (result === false) {
254
+ return; // Cancel navigation
255
+ }
256
+ }
257
+
258
+ // Save scroll position before navigation
259
+ saveScrollPosition();
260
+
261
+ // Update browser history
262
+ const existingScrollKey = scrollRestoration ? getScrollKey() : undefined;
263
+ const scrollKey =
264
+ method === 'replaceState' && existingScrollKey ? existingScrollKey : createScrollKey();
265
+ const fullPath = useHash ? `#${path}` : `${base}${path}`;
266
+ const baseState =
267
+ scrollRestoration && history.state && typeof history.state === 'object'
268
+ ? sanitizeHistoryState(history.state as Record<string, unknown>)
269
+ : {};
270
+ const state = scrollRestoration ? { ...baseState, __bqScrollKey: scrollKey } : {};
271
+ history[method](state, '', fullPath);
272
+ currentScrollKey = scrollKey;
273
+
274
+ // Update route signal
275
+ syncRoute();
276
+
277
+ // Scroll to top on push navigation
278
+ if (scrollRestoration && method === 'pushState') {
279
+ window.scrollTo(0, 0);
280
+ }
281
+
282
+ // Run afterEach hooks
283
+ for (const hook of afterHooks) {
284
+ hook(routeSignal.value, from);
285
+ }
286
+ } finally {
287
+ endNavigation();
288
+ }
289
+ };
290
+
291
+ /**
292
+ * Handle popstate events (back/forward).
293
+ */
294
+ const handlePopState = async (event: PopStateEvent): Promise<void> => {
295
+ beginNavigation();
296
+ try {
297
+ const { pathname, search, hash } = getCurrentPath();
298
+ const from = routeSignal.value;
299
+ const to = createRoute(pathname, search, hash, flatRoutes);
300
+
301
+ // Check for redirectTo on the matched route
302
+ if (to.matched?.redirectTo) {
303
+ await performNavigation(to.matched.redirectTo, 'replaceState');
304
+ return;
305
+ }
306
+
307
+ // Run route-level beforeEnter guard
308
+ if (to.matched?.beforeEnter) {
309
+ const result = await to.matched.beforeEnter(to, from);
310
+ if (result === false) {
311
+ // Restore previous state with full URL (including query/hash)
312
+ const queryString = new URLSearchParams(
313
+ Object.entries(from.query).flatMap(([key, value]) =>
314
+ Array.isArray(value) ? value.map((v) => [key, v]) : [[key, value]]
315
+ )
316
+ ).toString();
317
+ const searchStr = queryString ? `?${queryString}` : '';
318
+ const hashStr = from.hash ? `#${from.hash}` : '';
319
+ const restorePath = useHash
320
+ ? `#${from.path}${searchStr}${hashStr}`
321
+ : `${base}${from.path}${searchStr}${hashStr}`;
322
+ history.replaceState(getRestoreHistoryState(), '', restorePath);
323
+ return;
324
+ }
325
+ }
326
+
327
+ // Run beforeEach guards (supports async guards)
328
+ for (const guard of beforeGuards) {
329
+ const result = await guard(to, from);
330
+ if (result === false) {
331
+ // Restore previous state with full URL (including query/hash)
332
+ const queryString = new URLSearchParams(
333
+ Object.entries(from.query).flatMap(([key, value]) =>
334
+ Array.isArray(value) ? value.map((v) => [key, v]) : [[key, value]]
335
+ )
336
+ ).toString();
337
+ const search = queryString ? `?${queryString}` : '';
338
+ const hash = from.hash ? `#${from.hash}` : '';
339
+ const restorePath = useHash
340
+ ? `#${from.path}${search}${hash}`
341
+ : `${base}${from.path}${search}${hash}`;
342
+ history.replaceState(getRestoreHistoryState(), '', restorePath);
343
+ return;
344
+ }
345
+ }
346
+
347
+ // Save scroll position of the page we're leaving
348
+ saveScrollPosition(currentScrollKey);
349
+
350
+ // Update scroll key from history state
351
+ currentScrollKey =
352
+ (event.state as { __bqScrollKey?: string } | null)?.__bqScrollKey ?? getScrollKey();
353
+
354
+ syncRoute();
355
+
356
+ // Restore scroll position for the entry we're navigating to
357
+ restoreScrollPosition(currentScrollKey);
358
+
359
+ for (const hook of afterHooks) {
360
+ hook(routeSignal.value, from);
361
+ }
362
+ } finally {
363
+ endNavigation();
364
+ }
365
+ };
366
+
367
+ // Attach popstate listener
368
+ window.addEventListener('popstate', handlePopState);
369
+
370
+ // Initialize route
371
+ syncRoute();
372
+
373
+ const router: Router = {
374
+ push: (path: string) => performNavigation(path, 'pushState'),
375
+ replace: (path: string) => performNavigation(path, 'replaceState'),
376
+ back: () => history.back(),
377
+ forward: () => history.forward(),
378
+ go: (delta: number) => history.go(delta),
379
+
380
+ beforeEach: (guard: NavigationGuard) => {
381
+ beforeGuards.push(guard);
382
+ return () => {
383
+ const index = beforeGuards.indexOf(guard);
384
+ if (index > -1) beforeGuards.splice(index, 1);
385
+ };
386
+ },
387
+
388
+ afterEach: (hook: (to: Route, from: Route) => void) => {
389
+ afterHooks.push(hook);
390
+ return () => {
391
+ const index = afterHooks.indexOf(hook);
392
+ if (index > -1) afterHooks.splice(index, 1);
393
+ };
394
+ },
395
+
396
+ currentRoute,
397
+ routes: flatRoutes,
398
+ base,
399
+ hash: useHash,
400
+
401
+ destroy: () => {
402
+ window.removeEventListener('popstate', handlePopState);
403
+ beforeGuards.length = 0;
404
+ afterHooks.length = 0;
405
+ scrollPositions.clear();
406
+ // Restore the previous scroll restoration mode on destroy
407
+ if (
408
+ previousScrollRestoration !== null &&
409
+ typeof history !== 'undefined' &&
410
+ 'scrollRestoration' in history
411
+ ) {
412
+ history.scrollRestoration = previousScrollRestoration;
413
+ }
414
+ resetNavigationState();
415
+ setActiveRouter(null);
416
+ },
417
+ };
418
+
419
+ setActiveRouter(router);
420
+ return router;
421
+ };