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.
- package/README.md +1 -1
- package/package.json +4 -4
- package/src/index.js +75 -12
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "what-router",
|
|
3
|
-
"version": "0.5.
|
|
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/
|
|
33
|
+
"url": "https://github.com/CelsianJs/whatfw"
|
|
34
34
|
},
|
|
35
35
|
"bugs": {
|
|
36
|
-
"url": "https://github.com/
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
326
|
+
const isActive = hrefPath === '/'
|
|
270
327
|
? currentPath === '/'
|
|
271
|
-
: currentPath ===
|
|
272
|
-
const isExactActive = currentPath ===
|
|
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(() =>
|
|
449
|
+
.catch(() => {
|
|
450
|
+
if (!cancelled) status.set('denied');
|
|
451
|
+
});
|
|
452
|
+
return () => { cancelled = true; };
|
|
390
453
|
});
|
|
391
454
|
|
|
392
455
|
const currentStatus = status();
|