what-router 0.5.4 → 0.5.5

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 (3) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/index.js +75 -12
package/README.md CHANGED
@@ -181,7 +181,7 @@ enableScrollRestoration(); // call once at app entry
181
181
  ## Links
182
182
 
183
183
  - [Documentation](https://whatfw.com)
184
- - [GitHub](https://github.com/zvndev/what-fw)
184
+ - [GitHub](https://github.com/CelsianJs/whatfw)
185
185
 
186
186
  ## License
187
187
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "what-router",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "What Framework - File-based & programmatic router with View Transitions",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -23,17 +23,17 @@
23
23
  "spa",
24
24
  "what-framework"
25
25
  ],
26
- "author": "",
26
+ "author": "ZVN DEV (https://zvndev.com)",
27
27
  "license": "MIT",
28
28
  "peerDependencies": {
29
29
  "what-core": "^0.5.3"
30
30
  },
31
31
  "repository": {
32
32
  "type": "git",
33
- "url": "https://github.com/zvndev/what-fw"
33
+ "url": "https://github.com/CelsianJs/whatfw"
34
34
  },
35
35
  "bugs": {
36
- "url": "https://github.com/zvndev/what-fw/issues"
36
+ "url": "https://github.com/CelsianJs/whatfw/issues"
37
37
  },
38
38
  "homepage": "https://whatfw.com"
39
39
  }
package/src/index.js CHANGED
@@ -28,19 +28,25 @@ export const route = {
28
28
  // --- Navigation with View Transitions ---
29
29
 
30
30
  export async function navigate(to, opts = {}) {
31
- const { replace = false, state = null, transition = true } = opts;
31
+ const { replace = false, state = null, transition = true, _fromPopstate = false } = opts;
32
32
 
33
33
  // Don't navigate if already on the same URL
34
34
  if (to === _url()) return;
35
35
 
36
+ // Prevent concurrent navigations — wait for current to finish
37
+ if (_isNavigating.peek()) return;
38
+
36
39
  _isNavigating.set(true);
37
40
  _navigationError.set(null);
38
41
 
39
42
  const doNavigation = () => {
40
- if (replace) {
41
- history.replaceState(state, '', to);
42
- } else {
43
- history.pushState(state, '', to);
43
+ // Skip history manipulation on popstate (browser already updated the URL)
44
+ if (!_fromPopstate) {
45
+ if (replace) {
46
+ history.replaceState(state, '', to);
47
+ } else {
48
+ history.pushState(state, '', to);
49
+ }
44
50
  }
45
51
  _url.set(to);
46
52
  _isNavigating.set(false);
@@ -58,10 +64,12 @@ export async function navigate(to, opts = {}) {
58
64
  }
59
65
  }
60
66
 
61
- // Back/forward support
67
+ // Back/forward support — route through navigate() so middleware runs
62
68
  if (typeof window !== 'undefined') {
63
69
  window.addEventListener('popstate', () => {
64
- _url.set(location.pathname + location.search + location.hash);
70
+ const newUrl = location.pathname + location.search + location.hash;
71
+ // Use _fromPopstate flag so navigate() skips pushState (browser already updated URL)
72
+ navigate(newUrl, { replace: true, _fromPopstate: true, transition: false });
65
73
  });
66
74
  }
67
75
 
@@ -138,7 +146,19 @@ function parseQuery(search) {
138
146
  const qs = search.startsWith('?') ? search.slice(1) : search;
139
147
  for (const pair of qs.split('&')) {
140
148
  const [key, val] = pair.split('=');
141
- if (key) params[decodeURIComponent(key)] = val ? decodeURIComponent(val) : '';
149
+ if (!key) continue;
150
+ const decodedKey = decodeURIComponent(key);
151
+ const decodedVal = val ? decodeURIComponent(val) : '';
152
+ if (decodedKey in params) {
153
+ // Collect repeated keys into arrays
154
+ if (Array.isArray(params[decodedKey])) {
155
+ params[decodedKey].push(decodedVal);
156
+ } else {
157
+ params[decodedKey] = [params[decodedKey], decodedVal];
158
+ }
159
+ } else {
160
+ params[decodedKey] = decodedVal;
161
+ }
142
162
  }
143
163
  return params;
144
164
  }
@@ -174,6 +194,10 @@ function buildLayoutChain(route, routes) {
174
194
  return layouts;
175
195
  }
176
196
 
197
+ // --- Middleware redirect loop detection ---
198
+ const _redirectHistory = [];
199
+ const MAX_REDIRECTS = 10;
200
+
177
201
  // --- Router Component ---
178
202
 
179
203
  export function Router({ routes, fallback, globalLayout }) {
@@ -203,12 +227,43 @@ export function Router({ routes, fallback, globalLayout }) {
203
227
  return h('div', { class: 'what-403' }, h('h1', null, '403'), h('p', null, 'Access denied'));
204
228
  }
205
229
  if (typeof result === 'string') {
230
+ // Redirect loop detection
231
+ _redirectHistory.push(result);
232
+ if (_redirectHistory.length > MAX_REDIRECTS) {
233
+ const cycle = _redirectHistory.slice(-5).join(' → ');
234
+ _redirectHistory.length = 0;
235
+ console.error(`[what-router] Redirect loop detected: ${cycle}`);
236
+ _isNavigating.set(false);
237
+ return h('div', { class: 'what-redirect-loop' },
238
+ h('h1', null, 'Redirect Loop'),
239
+ h('p', null, 'Too many redirects. Check your middleware configuration.')
240
+ );
241
+ }
242
+ // Check for direct cycle (A → B → A)
243
+ const seen = new Set();
244
+ let hasCycle = false;
245
+ for (const url of _redirectHistory) {
246
+ if (seen.has(url)) { hasCycle = true; break; }
247
+ seen.add(url);
248
+ }
249
+ if (hasCycle) {
250
+ const cycle = _redirectHistory.join(' → ');
251
+ _redirectHistory.length = 0;
252
+ console.error(`[what-router] Redirect cycle detected: ${cycle}`);
253
+ _isNavigating.set(false);
254
+ return h('div', { class: 'what-redirect-loop' },
255
+ h('h1', null, 'Redirect Loop'),
256
+ h('p', null, 'Circular redirect detected. Check your middleware configuration.')
257
+ );
258
+ }
206
259
  // Middleware returned a redirect path
207
260
  navigate(result, { replace: true });
208
261
  return null;
209
262
  }
210
263
  }
211
264
  }
265
+ // Successful render — clear redirect history
266
+ _redirectHistory.length = 0;
212
267
 
213
268
  // Build element with loading state support
214
269
  let element;
@@ -265,11 +320,13 @@ export function Link({
265
320
  ...rest
266
321
  }) {
267
322
  const currentPath = route.path;
323
+ // Strip query string and hash from href for path comparison
324
+ const hrefPath = href.split('?')[0].split('#')[0];
268
325
  // Segment-boundary matching: '/blog' matches '/blog/123' but not '/blog-archive'
269
- const isActive = href === '/'
326
+ const isActive = hrefPath === '/'
270
327
  ? currentPath === '/'
271
- : currentPath === href || currentPath.startsWith(href + '/');
272
- const isExactActive = currentPath === href;
328
+ : currentPath === hrefPath || currentPath.startsWith(hrefPath + '/');
329
+ const isExactActive = currentPath === hrefPath;
273
330
 
274
331
  const classes = [
275
332
  cls || className,
@@ -379,14 +436,20 @@ export function asyncGuard(check, options = {}) {
379
436
  return function AsyncGuardedRoute(props) {
380
437
  const status = signal('pending');
381
438
  const checkResult = signal(null);
439
+ let cancelled = false;
382
440
 
383
441
  effect(() => {
442
+ cancelled = false;
384
443
  Promise.resolve(check(props))
385
444
  .then(result => {
445
+ if (cancelled) return;
386
446
  checkResult.set(result);
387
447
  status.set(result ? 'allowed' : 'denied');
388
448
  })
389
- .catch(() => status.set('denied'));
449
+ .catch(() => {
450
+ if (!cancelled) status.set('denied');
451
+ });
452
+ return () => { cancelled = true; };
390
453
  });
391
454
 
392
455
  const currentStatus = status();