pulse-js-framework 1.8.1 → 1.8.3
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/package.json +1 -1
- package/runtime/router.js +548 -170
package/package.json
CHANGED
package/runtime/router.js
CHANGED
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* - Scroll restoration
|
|
12
12
|
* - Lazy-loaded routes
|
|
13
13
|
* - Middleware support
|
|
14
|
+
* - Route aliases and redirects
|
|
15
|
+
* - Typed query parameter parsing
|
|
16
|
+
* - Route groups with shared layouts
|
|
17
|
+
* - Navigation loading state
|
|
18
|
+
* - Route transitions and lifecycle hooks (onBeforeLeave, onAfterEnter)
|
|
14
19
|
*/
|
|
15
20
|
|
|
16
21
|
import { pulse, effect, batch } from './pulse.js';
|
|
@@ -417,17 +422,57 @@ function normalizeRoute(pattern, config) {
|
|
|
417
422
|
};
|
|
418
423
|
}
|
|
419
424
|
|
|
420
|
-
// Full format: pattern -> { handler, meta, beforeEnter, children }
|
|
425
|
+
// Full format: pattern -> { handler, meta, beforeEnter, children, alias, layout, group }
|
|
421
426
|
return {
|
|
422
427
|
pattern,
|
|
423
428
|
handler: config.handler || config.component,
|
|
424
429
|
meta: config.meta || {},
|
|
425
430
|
beforeEnter: config.beforeEnter || null,
|
|
426
431
|
children: config.children || null,
|
|
427
|
-
redirect: config.redirect || null
|
|
432
|
+
redirect: config.redirect || null,
|
|
433
|
+
alias: config.alias || null,
|
|
434
|
+
layout: config.layout || null,
|
|
435
|
+
group: config.group || false
|
|
428
436
|
};
|
|
429
437
|
}
|
|
430
438
|
|
|
439
|
+
/**
|
|
440
|
+
* Build a query string from an object, supporting arrays and skipping null/undefined
|
|
441
|
+
*
|
|
442
|
+
* @param {Object} query - Query parameters object
|
|
443
|
+
* @returns {string} Encoded query string (without leading ?)
|
|
444
|
+
*
|
|
445
|
+
* @example
|
|
446
|
+
* buildQueryString({ q: 'hello world', tags: ['a', 'b'] })
|
|
447
|
+
* // 'q=hello+world&tags=a&tags=b'
|
|
448
|
+
*
|
|
449
|
+
* buildQueryString({ a: 'x', b: null, c: undefined })
|
|
450
|
+
* // 'a=x'
|
|
451
|
+
*/
|
|
452
|
+
function buildQueryString(query) {
|
|
453
|
+
if (!query || typeof query !== 'object') return '';
|
|
454
|
+
|
|
455
|
+
const params = new URLSearchParams();
|
|
456
|
+
|
|
457
|
+
for (const [key, value] of Object.entries(query)) {
|
|
458
|
+
// Skip null and undefined values
|
|
459
|
+
if (value === null || value === undefined) continue;
|
|
460
|
+
|
|
461
|
+
if (Array.isArray(value)) {
|
|
462
|
+
// Array values: ?tags=a&tags=b
|
|
463
|
+
for (const item of value) {
|
|
464
|
+
if (item !== null && item !== undefined) {
|
|
465
|
+
params.append(key, String(item));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
} else {
|
|
469
|
+
params.append(key, String(value));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return params.toString();
|
|
474
|
+
}
|
|
475
|
+
|
|
431
476
|
/**
|
|
432
477
|
* Match a path against a route pattern
|
|
433
478
|
*/
|
|
@@ -452,6 +497,27 @@ const QUERY_LIMITS = {
|
|
|
452
497
|
maxParams: 50 // Maximum number of query parameters
|
|
453
498
|
};
|
|
454
499
|
|
|
500
|
+
/**
|
|
501
|
+
* Parse a single query value into its typed representation
|
|
502
|
+
* Only converts when parseQueryTypes is enabled
|
|
503
|
+
*
|
|
504
|
+
* @param {string} value - Raw string value
|
|
505
|
+
* @returns {string|number|boolean} Typed value
|
|
506
|
+
*/
|
|
507
|
+
function parseTypedValue(value) {
|
|
508
|
+
// Boolean detection
|
|
509
|
+
if (value === 'true') return true;
|
|
510
|
+
if (value === 'false') return false;
|
|
511
|
+
|
|
512
|
+
// Number detection (strict: only numeric strings, not hex/octal/empty)
|
|
513
|
+
if (value !== '' && !isNaN(value) && !isNaN(parseFloat(value))) {
|
|
514
|
+
const num = Number(value);
|
|
515
|
+
if (isFinite(num)) return num;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return value;
|
|
519
|
+
}
|
|
520
|
+
|
|
455
521
|
/**
|
|
456
522
|
* Parse query string into object with validation
|
|
457
523
|
*
|
|
@@ -461,11 +527,15 @@ const QUERY_LIMITS = {
|
|
|
461
527
|
* - Max parameters: 50
|
|
462
528
|
*
|
|
463
529
|
* @param {string} search - Query string (with or without leading ?)
|
|
530
|
+
* @param {Object} [options] - Parsing options
|
|
531
|
+
* @param {boolean} [options.typed=false] - Parse numbers and booleans from string values
|
|
464
532
|
* @returns {Object} Parsed query parameters
|
|
465
533
|
*/
|
|
466
|
-
function parseQuery(search) {
|
|
534
|
+
function parseQuery(search, options = {}) {
|
|
467
535
|
if (!search) return {};
|
|
468
536
|
|
|
537
|
+
const { typed = false } = options;
|
|
538
|
+
|
|
469
539
|
// Remove leading ? if present
|
|
470
540
|
let queryStr = search.startsWith('?') ? search.slice(1) : search;
|
|
471
541
|
|
|
@@ -493,6 +563,11 @@ function parseQuery(search) {
|
|
|
493
563
|
safeValue = value.slice(0, QUERY_LIMITS.maxValueLength);
|
|
494
564
|
}
|
|
495
565
|
|
|
566
|
+
// Apply typed parsing if enabled
|
|
567
|
+
if (typed) {
|
|
568
|
+
safeValue = parseTypedValue(safeValue);
|
|
569
|
+
}
|
|
570
|
+
|
|
496
571
|
if (key in query) {
|
|
497
572
|
// Multiple values for same key
|
|
498
573
|
if (Array.isArray(query[key])) {
|
|
@@ -508,6 +583,9 @@ function parseQuery(search) {
|
|
|
508
583
|
return query;
|
|
509
584
|
}
|
|
510
585
|
|
|
586
|
+
// Issue #66: Active router instance for standalone lifecycle exports
|
|
587
|
+
let _activeRouter = null;
|
|
588
|
+
|
|
511
589
|
/**
|
|
512
590
|
* Create a router instance
|
|
513
591
|
*/
|
|
@@ -519,9 +597,20 @@ export function createRouter(options = {}) {
|
|
|
519
597
|
scrollBehavior = null, // Function to control scroll restoration
|
|
520
598
|
middleware: initialMiddleware = [], // Middleware functions
|
|
521
599
|
persistScroll = false, // Persist scroll positions to sessionStorage
|
|
522
|
-
persistScrollKey = 'pulse-router-scroll' // Storage key for scroll persistence
|
|
600
|
+
persistScrollKey = 'pulse-router-scroll', // Storage key for scroll persistence
|
|
601
|
+
parseQueryTypes = false, // Parse typed query params (numbers, booleans)
|
|
602
|
+
transition = null // CSS transition config { enterClass, enterActiveClass, leaveClass, leaveActiveClass, duration }
|
|
523
603
|
} = options;
|
|
524
604
|
|
|
605
|
+
// Validate transition duration to prevent DoS (max 10s)
|
|
606
|
+
const transitionConfig = transition ? {
|
|
607
|
+
enterClass: transition.enterClass || 'route-enter',
|
|
608
|
+
enterActiveClass: transition.enterActiveClass || 'route-enter-active',
|
|
609
|
+
leaveClass: transition.leaveClass || 'route-leave',
|
|
610
|
+
leaveActiveClass: transition.leaveActiveClass || 'route-leave-active',
|
|
611
|
+
duration: Math.min(Math.max(transition.duration || 300, 0), 10000)
|
|
612
|
+
} : null;
|
|
613
|
+
|
|
525
614
|
// Middleware array (mutable for dynamic registration)
|
|
526
615
|
const middleware = [...initialMiddleware];
|
|
527
616
|
|
|
@@ -585,14 +674,27 @@ export function createRouter(options = {}) {
|
|
|
585
674
|
// Compile routes (supports nested routes)
|
|
586
675
|
const compiledRoutes = [];
|
|
587
676
|
|
|
588
|
-
function compileRoutes(routeConfig, parentPath = '') {
|
|
677
|
+
function compileRoutes(routeConfig, parentPath = '', parentLayout = null) {
|
|
589
678
|
for (const [pattern, config] of Object.entries(routeConfig)) {
|
|
590
679
|
const normalized = normalizeRoute(pattern, config);
|
|
680
|
+
|
|
681
|
+
// Issue #71: Route groups — key starting with _ and group: true
|
|
682
|
+
// Group children get NO URL prefix from the group key
|
|
683
|
+
if (normalized.group && normalized.children) {
|
|
684
|
+
const groupLayout = normalized.layout || parentLayout;
|
|
685
|
+
compileRoutes(normalized.children, parentPath, groupLayout);
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
|
|
591
689
|
const fullPattern = parentPath + pattern;
|
|
592
690
|
|
|
691
|
+
// Inherit layout from parent group if not specified
|
|
692
|
+
const routeLayout = normalized.layout || parentLayout;
|
|
693
|
+
|
|
593
694
|
const route = {
|
|
594
695
|
...normalized,
|
|
595
696
|
pattern: fullPattern,
|
|
697
|
+
layout: routeLayout,
|
|
596
698
|
...parsePattern(fullPattern)
|
|
597
699
|
};
|
|
598
700
|
|
|
@@ -601,9 +703,13 @@ export function createRouter(options = {}) {
|
|
|
601
703
|
// Insert into trie for fast lookup
|
|
602
704
|
routeTrie.insert(fullPattern, route);
|
|
603
705
|
|
|
706
|
+
// Issue #68: Route aliases — the alias route is already in the trie at its own path
|
|
707
|
+
// (e.g., '/fake' with alias: '/real'). The navigate() function resolves
|
|
708
|
+
// aliases by following the alias chain to find the target handler.
|
|
709
|
+
|
|
604
710
|
// Compile children (nested routes)
|
|
605
711
|
if (normalized.children) {
|
|
606
|
-
compileRoutes(normalized.children, fullPattern);
|
|
712
|
+
compileRoutes(normalized.children, fullPattern, routeLayout);
|
|
607
713
|
}
|
|
608
714
|
}
|
|
609
715
|
}
|
|
@@ -615,6 +721,13 @@ export function createRouter(options = {}) {
|
|
|
615
721
|
const resolveHooks = [];
|
|
616
722
|
const afterHooks = [];
|
|
617
723
|
|
|
724
|
+
// Issue #66: Route lifecycle hooks (per-route, registered by components)
|
|
725
|
+
const beforeLeaveHooks = new Map(); // path → [callbacks]
|
|
726
|
+
const afterEnterHooks = new Map(); // path → [callbacks]
|
|
727
|
+
|
|
728
|
+
// Issue #72: Loading change listeners
|
|
729
|
+
const loadingListeners = [];
|
|
730
|
+
|
|
618
731
|
/**
|
|
619
732
|
* Get current path based on mode
|
|
620
733
|
*/
|
|
@@ -655,107 +768,152 @@ export function createRouter(options = {}) {
|
|
|
655
768
|
async function navigate(path, options = {}) {
|
|
656
769
|
const { replace = false, query = {}, state = null } = options;
|
|
657
770
|
|
|
658
|
-
//
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
let fullPath = path;
|
|
663
|
-
const queryString = new URLSearchParams(query).toString();
|
|
664
|
-
if (queryString) {
|
|
665
|
-
fullPath += '?' + queryString;
|
|
771
|
+
// Issue #72: Set loading state at start of navigation
|
|
772
|
+
const hasAsyncWork = middleware.length > 0 || beforeHooks.length > 0 || resolveHooks.length > 0;
|
|
773
|
+
if (hasAsyncWork) {
|
|
774
|
+
isLoading.set(true);
|
|
666
775
|
}
|
|
667
776
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
777
|
+
try {
|
|
778
|
+
// Find matching route first (needed for beforeEnter guard)
|
|
779
|
+
let match = findRoute(path);
|
|
780
|
+
|
|
781
|
+
// Issue #68: Resolve alias — follow alias chain (with loop protection)
|
|
782
|
+
const visited = new Set();
|
|
783
|
+
while (match?.route?.alias && !visited.has(match.route.pattern)) {
|
|
784
|
+
visited.add(match.route.pattern);
|
|
785
|
+
const aliasTarget = match.route.alias;
|
|
786
|
+
const aliasMatch = findRoute(aliasTarget);
|
|
787
|
+
if (aliasMatch) {
|
|
788
|
+
match = aliasMatch;
|
|
789
|
+
} else {
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
675
793
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
};
|
|
794
|
+
// Issue #70: Build full path with query using buildQueryString (array + null support)
|
|
795
|
+
let fullPath = path;
|
|
796
|
+
const queryString = buildQueryString(query);
|
|
797
|
+
if (queryString) {
|
|
798
|
+
fullPath += '?' + queryString;
|
|
799
|
+
}
|
|
683
800
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
801
|
+
// Handle redirect
|
|
802
|
+
if (match?.route?.redirect) {
|
|
803
|
+
const redirectPath = typeof match.route.redirect === 'function'
|
|
804
|
+
? match.route.redirect({ params: match.params, query })
|
|
805
|
+
: match.route.redirect;
|
|
806
|
+
return navigate(redirectPath, { replace: true });
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Create navigation context for guards
|
|
810
|
+
const from = {
|
|
811
|
+
path: currentPath.peek(),
|
|
812
|
+
params: currentParams.peek(),
|
|
813
|
+
query: currentQuery.peek(),
|
|
814
|
+
meta: currentMeta.peek()
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
// Issue #70: Parse query with typed option
|
|
818
|
+
const parsedQuery = parseQuery(queryString, { typed: parseQueryTypes });
|
|
690
819
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
820
|
+
const to = {
|
|
821
|
+
path,
|
|
822
|
+
params: match?.params || {},
|
|
823
|
+
query: parsedQuery,
|
|
824
|
+
meta: match?.route?.meta || {}
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
// Issue #66: Run beforeLeave hooks for the current route
|
|
828
|
+
const leavePath = currentPath.peek();
|
|
829
|
+
const leaveCallbacks = beforeLeaveHooks.get(leavePath);
|
|
830
|
+
if (leaveCallbacks && leaveCallbacks.length > 0) {
|
|
831
|
+
for (const cb of [...leaveCallbacks]) {
|
|
832
|
+
const result = await cb(to, from);
|
|
833
|
+
if (result === false) return false;
|
|
834
|
+
}
|
|
697
835
|
}
|
|
698
|
-
|
|
699
|
-
|
|
836
|
+
|
|
837
|
+
// Run middleware if configured
|
|
838
|
+
if (middleware.length > 0) {
|
|
839
|
+
const runMiddleware = createMiddlewareRunner(middleware);
|
|
840
|
+
const middlewareResult = await runMiddleware({ to, from });
|
|
841
|
+
if (middlewareResult.aborted) {
|
|
842
|
+
return false;
|
|
843
|
+
}
|
|
844
|
+
if (middlewareResult.redirectPath) {
|
|
845
|
+
return navigate(middlewareResult.redirectPath, { replace: true });
|
|
846
|
+
}
|
|
847
|
+
// Merge middleware meta into route meta
|
|
848
|
+
Object.assign(to.meta, middlewareResult.meta);
|
|
700
849
|
}
|
|
701
|
-
// Merge middleware meta into route meta
|
|
702
|
-
Object.assign(to.meta, middlewareResult.meta);
|
|
703
|
-
}
|
|
704
850
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
851
|
+
// Run global beforeEach hooks
|
|
852
|
+
for (const hook of beforeHooks) {
|
|
853
|
+
const result = await hook(to, from);
|
|
854
|
+
if (result === false) return false;
|
|
855
|
+
if (typeof result === 'string') {
|
|
856
|
+
return navigate(result, options);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Run per-route beforeEnter guard
|
|
861
|
+
if (match?.route?.beforeEnter) {
|
|
862
|
+
const result = await match.route.beforeEnter(to, from);
|
|
863
|
+
if (result === false) return false;
|
|
864
|
+
if (typeof result === 'string') {
|
|
865
|
+
return navigate(result, options);
|
|
866
|
+
}
|
|
711
867
|
}
|
|
712
|
-
}
|
|
713
868
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
869
|
+
// Run beforeResolve hooks (after per-route guards)
|
|
870
|
+
for (const hook of resolveHooks) {
|
|
871
|
+
const result = await hook(to, from);
|
|
872
|
+
if (result === false) return false;
|
|
873
|
+
if (typeof result === 'string') {
|
|
874
|
+
return navigate(result, options);
|
|
875
|
+
}
|
|
720
876
|
}
|
|
721
|
-
}
|
|
722
877
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
878
|
+
// Save scroll position before leaving
|
|
879
|
+
const currentFullPath = currentPath.peek();
|
|
880
|
+
if (currentFullPath) {
|
|
881
|
+
scrollPositions.set(currentFullPath, {
|
|
882
|
+
x: window.scrollX,
|
|
883
|
+
y: window.scrollY
|
|
884
|
+
});
|
|
885
|
+
persistScrollPositions();
|
|
729
886
|
}
|
|
730
|
-
}
|
|
731
887
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
scrollPositions.set(currentFullPath, {
|
|
736
|
-
x: window.scrollX,
|
|
737
|
-
y: window.scrollY
|
|
738
|
-
});
|
|
739
|
-
persistScrollPositions();
|
|
740
|
-
}
|
|
888
|
+
// Update URL
|
|
889
|
+
const url = mode === 'hash' ? `#${fullPath}` : `${base}${fullPath}`;
|
|
890
|
+
const historyState = { path: fullPath, ...(state || {}) };
|
|
741
891
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
892
|
+
if (replace) {
|
|
893
|
+
window.history.replaceState(historyState, '', url);
|
|
894
|
+
} else {
|
|
895
|
+
window.history.pushState(historyState, '', url);
|
|
896
|
+
}
|
|
745
897
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
} else {
|
|
749
|
-
window.history.pushState(historyState, '', url);
|
|
750
|
-
}
|
|
898
|
+
// Update reactive state
|
|
899
|
+
await updateRoute(path, parsedQuery, match);
|
|
751
900
|
|
|
752
|
-
|
|
753
|
-
|
|
901
|
+
// Handle scroll behavior
|
|
902
|
+
handleScroll(to, from, scrollPositions.get(path));
|
|
754
903
|
|
|
755
|
-
|
|
756
|
-
|
|
904
|
+
// Issue #66: Run afterEnter hooks for the new route
|
|
905
|
+
const enterCallbacks = afterEnterHooks.get(path);
|
|
906
|
+
if (enterCallbacks && enterCallbacks.length > 0) {
|
|
907
|
+
for (const cb of [...enterCallbacks]) {
|
|
908
|
+
cb(to);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
757
911
|
|
|
758
|
-
|
|
912
|
+
return true;
|
|
913
|
+
} finally {
|
|
914
|
+
// Issue #72: Always reset loading state
|
|
915
|
+
isLoading.set(false);
|
|
916
|
+
}
|
|
759
917
|
}
|
|
760
918
|
|
|
761
919
|
/**
|
|
@@ -911,6 +1069,10 @@ export function createRouter(options = {}) {
|
|
|
911
1069
|
*
|
|
912
1070
|
* MEMORY SAFETY: Aborts any pending lazy loads when navigating away
|
|
913
1071
|
* to prevent stale callbacks from updating the DOM.
|
|
1072
|
+
*
|
|
1073
|
+
* Supports:
|
|
1074
|
+
* - Route groups with shared layouts (#71)
|
|
1075
|
+
* - CSS route transitions (#66)
|
|
914
1076
|
*/
|
|
915
1077
|
function outlet(container) {
|
|
916
1078
|
if (typeof container === 'string') {
|
|
@@ -920,6 +1082,56 @@ export function createRouter(options = {}) {
|
|
|
920
1082
|
let currentView = null;
|
|
921
1083
|
let cleanup = null;
|
|
922
1084
|
|
|
1085
|
+
/**
|
|
1086
|
+
* Remove old view, optionally with CSS transition
|
|
1087
|
+
*/
|
|
1088
|
+
function removeOldView(oldView, onDone) {
|
|
1089
|
+
if (!oldView) {
|
|
1090
|
+
onDone();
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Abort any pending lazy loads before removing the view
|
|
1095
|
+
if (oldView._pulseAbortLazyLoad) {
|
|
1096
|
+
oldView._pulseAbortLazyLoad();
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Issue #66: CSS transition on leave
|
|
1100
|
+
if (transitionConfig && oldView.classList) {
|
|
1101
|
+
oldView.classList.add(transitionConfig.leaveClass);
|
|
1102
|
+
requestAnimationFrame(() => {
|
|
1103
|
+
oldView.classList.add(transitionConfig.leaveActiveClass);
|
|
1104
|
+
});
|
|
1105
|
+
setTimeout(() => {
|
|
1106
|
+
oldView.classList.remove(transitionConfig.leaveClass, transitionConfig.leaveActiveClass);
|
|
1107
|
+
container.replaceChildren();
|
|
1108
|
+
onDone();
|
|
1109
|
+
}, transitionConfig.duration);
|
|
1110
|
+
} else {
|
|
1111
|
+
container.replaceChildren();
|
|
1112
|
+
onDone();
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Add new view, optionally with CSS transition
|
|
1118
|
+
*/
|
|
1119
|
+
function addNewView(view) {
|
|
1120
|
+
container.appendChild(view);
|
|
1121
|
+
currentView = view;
|
|
1122
|
+
|
|
1123
|
+
// Issue #66: CSS transition on enter
|
|
1124
|
+
if (transitionConfig && view.classList) {
|
|
1125
|
+
view.classList.add(transitionConfig.enterClass);
|
|
1126
|
+
requestAnimationFrame(() => {
|
|
1127
|
+
view.classList.add(transitionConfig.enterActiveClass);
|
|
1128
|
+
setTimeout(() => {
|
|
1129
|
+
view.classList.remove(transitionConfig.enterClass, transitionConfig.enterActiveClass);
|
|
1130
|
+
}, transitionConfig.duration);
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
923
1135
|
effect(() => {
|
|
924
1136
|
const route = currentRoute.get();
|
|
925
1137
|
const params = currentParams.get();
|
|
@@ -927,85 +1139,107 @@ export function createRouter(options = {}) {
|
|
|
927
1139
|
|
|
928
1140
|
// Cleanup previous view
|
|
929
1141
|
if (cleanup) cleanup();
|
|
930
|
-
if (currentView) {
|
|
931
|
-
// Abort any pending lazy loads before removing the view
|
|
932
|
-
if (currentView._pulseAbortLazyLoad) {
|
|
933
|
-
currentView._pulseAbortLazyLoad();
|
|
934
|
-
}
|
|
935
|
-
container.replaceChildren();
|
|
936
|
-
}
|
|
937
1142
|
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1143
|
+
const oldView = currentView;
|
|
1144
|
+
currentView = null;
|
|
1145
|
+
|
|
1146
|
+
function renderRoute() {
|
|
1147
|
+
if (route && route.handler) {
|
|
1148
|
+
// Create context for the route handler
|
|
1149
|
+
const ctx = {
|
|
1150
|
+
params,
|
|
1151
|
+
query,
|
|
1152
|
+
path: currentPath.peek(),
|
|
1153
|
+
navigate,
|
|
1154
|
+
router
|
|
1155
|
+
};
|
|
1156
|
+
|
|
1157
|
+
// Helper to handle errors
|
|
1158
|
+
const handleError = (error) => {
|
|
1159
|
+
routeError.set(error);
|
|
1160
|
+
log.error('Route component error:', error);
|
|
1161
|
+
|
|
1162
|
+
if (onRouteError) {
|
|
1163
|
+
try {
|
|
1164
|
+
const errorView = onRouteError(error, ctx);
|
|
1165
|
+
if (errorView instanceof Node) {
|
|
1166
|
+
addNewView(errorView);
|
|
1167
|
+
return true;
|
|
1168
|
+
}
|
|
1169
|
+
} catch (handlerError) {
|
|
1170
|
+
log.error('Route error handler threw:', handlerError);
|
|
960
1171
|
}
|
|
961
|
-
} catch (handlerError) {
|
|
962
|
-
log.error('Route error handler threw:', handlerError);
|
|
963
1172
|
}
|
|
964
|
-
}
|
|
965
1173
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
}
|
|
1174
|
+
const errorEl = el('div.route-error', [
|
|
1175
|
+
el('h2', 'Route Error'),
|
|
1176
|
+
el('p', error.message || 'Failed to load route component')
|
|
1177
|
+
]);
|
|
1178
|
+
addNewView(errorEl);
|
|
1179
|
+
return true;
|
|
1180
|
+
};
|
|
1181
|
+
|
|
1182
|
+
// Call handler and render result (with error handling)
|
|
1183
|
+
let result;
|
|
1184
|
+
try {
|
|
1185
|
+
result = typeof route.handler === 'function'
|
|
1186
|
+
? route.handler(ctx)
|
|
1187
|
+
: route.handler;
|
|
1188
|
+
} catch (error) {
|
|
1189
|
+
handleError(error);
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
985
1192
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
const view = typeof component === 'function' ? component(ctx) : component;
|
|
998
|
-
if (view instanceof Node) {
|
|
999
|
-
container.appendChild(view);
|
|
1000
|
-
currentView = view;
|
|
1193
|
+
if (result instanceof Node) {
|
|
1194
|
+
// Issue #71: Wrap with layout if route has one
|
|
1195
|
+
let view = result;
|
|
1196
|
+
if (route.layout && typeof route.layout === 'function') {
|
|
1197
|
+
try {
|
|
1198
|
+
const layoutResult = route.layout(() => result, ctx);
|
|
1199
|
+
if (layoutResult instanceof Node) {
|
|
1200
|
+
view = layoutResult;
|
|
1201
|
+
}
|
|
1202
|
+
} catch (error) {
|
|
1203
|
+
log.error('Layout error:', error);
|
|
1001
1204
|
}
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
addNewView(view);
|
|
1208
|
+
routeError.set(null);
|
|
1209
|
+
} else if (result && typeof result.then === 'function') {
|
|
1210
|
+
// Async component
|
|
1211
|
+
isLoading.set(true);
|
|
1212
|
+
routeError.set(null);
|
|
1213
|
+
result
|
|
1214
|
+
.then(component => {
|
|
1215
|
+
isLoading.set(false);
|
|
1216
|
+
let view = typeof component === 'function' ? component(ctx) : component;
|
|
1217
|
+
if (view instanceof Node) {
|
|
1218
|
+
// Issue #71: Wrap with layout
|
|
1219
|
+
if (route.layout && typeof route.layout === 'function') {
|
|
1220
|
+
try {
|
|
1221
|
+
const layoutResult = route.layout(() => view, ctx);
|
|
1222
|
+
if (layoutResult instanceof Node) {
|
|
1223
|
+
view = layoutResult;
|
|
1224
|
+
}
|
|
1225
|
+
} catch (error) {
|
|
1226
|
+
log.error('Layout error:', error);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
addNewView(view);
|
|
1231
|
+
}
|
|
1232
|
+
})
|
|
1233
|
+
.catch(error => {
|
|
1234
|
+
isLoading.set(false);
|
|
1235
|
+
handleError(error);
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1007
1238
|
}
|
|
1008
1239
|
}
|
|
1240
|
+
|
|
1241
|
+
// Remove old view (with optional transition), then render new route
|
|
1242
|
+
removeOldView(oldView, renderRoute);
|
|
1009
1243
|
});
|
|
1010
1244
|
|
|
1011
1245
|
return container;
|
|
@@ -1098,38 +1332,76 @@ export function createRouter(options = {}) {
|
|
|
1098
1332
|
return matches;
|
|
1099
1333
|
}
|
|
1100
1334
|
|
|
1335
|
+
/**
|
|
1336
|
+
* Save current scroll and wait for popstate to fire
|
|
1337
|
+
* Used by back(), forward(), and go() to integrate with scroll restoration
|
|
1338
|
+
* @returns {Promise} Resolves after popstate fires or timeout
|
|
1339
|
+
*/
|
|
1340
|
+
function saveScrollAndWaitForPopState() {
|
|
1341
|
+
// Save current scroll position
|
|
1342
|
+
const currentFullPath = currentPath.peek();
|
|
1343
|
+
if (currentFullPath) {
|
|
1344
|
+
scrollPositions.set(currentFullPath, {
|
|
1345
|
+
x: window.scrollX,
|
|
1346
|
+
y: window.scrollY
|
|
1347
|
+
});
|
|
1348
|
+
persistScrollPositions();
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// Return a Promise that resolves on the next popstate (with 100ms fallback)
|
|
1352
|
+
return new Promise(resolve => {
|
|
1353
|
+
let resolved = false;
|
|
1354
|
+
const done = () => {
|
|
1355
|
+
if (resolved) return;
|
|
1356
|
+
resolved = true;
|
|
1357
|
+
window.removeEventListener('popstate', listener);
|
|
1358
|
+
resolve();
|
|
1359
|
+
};
|
|
1360
|
+
const listener = () => done();
|
|
1361
|
+
window.addEventListener('popstate', listener);
|
|
1362
|
+
setTimeout(done, 100);
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1101
1366
|
/**
|
|
1102
1367
|
* Navigate back in browser history
|
|
1103
|
-
*
|
|
1104
|
-
* @returns {
|
|
1368
|
+
* Saves scroll position before navigating
|
|
1369
|
+
* @returns {Promise} Resolves after navigation completes
|
|
1105
1370
|
* @example
|
|
1106
|
-
* router.back(); // Go to previous page
|
|
1371
|
+
* await router.back(); // Go to previous page
|
|
1107
1372
|
*/
|
|
1108
1373
|
function back() {
|
|
1374
|
+
const promise = saveScrollAndWaitForPopState();
|
|
1109
1375
|
window.history.back();
|
|
1376
|
+
return promise;
|
|
1110
1377
|
}
|
|
1111
1378
|
|
|
1112
1379
|
/**
|
|
1113
1380
|
* Navigate forward in browser history
|
|
1114
|
-
*
|
|
1115
|
-
* @returns {
|
|
1381
|
+
* Saves scroll position before navigating
|
|
1382
|
+
* @returns {Promise} Resolves after navigation completes
|
|
1116
1383
|
* @example
|
|
1117
|
-
* router.forward(); // Go to next page (if available)
|
|
1384
|
+
* await router.forward(); // Go to next page (if available)
|
|
1118
1385
|
*/
|
|
1119
1386
|
function forward() {
|
|
1387
|
+
const promise = saveScrollAndWaitForPopState();
|
|
1120
1388
|
window.history.forward();
|
|
1389
|
+
return promise;
|
|
1121
1390
|
}
|
|
1122
1391
|
|
|
1123
1392
|
/**
|
|
1124
1393
|
* Navigate to a specific position in browser history
|
|
1394
|
+
* Saves scroll position before navigating
|
|
1125
1395
|
* @param {number} delta - Number of entries to move (negative = back, positive = forward)
|
|
1126
|
-
* @returns {
|
|
1396
|
+
* @returns {Promise} Resolves after navigation completes
|
|
1127
1397
|
* @example
|
|
1128
|
-
* router.go(-2); // Go back 2 pages
|
|
1129
|
-
* router.go(1); // Go forward 1 page
|
|
1398
|
+
* await router.go(-2); // Go back 2 pages
|
|
1399
|
+
* await router.go(1); // Go forward 1 page
|
|
1130
1400
|
*/
|
|
1131
1401
|
function go(delta) {
|
|
1402
|
+
const promise = saveScrollAndWaitForPopState();
|
|
1132
1403
|
window.history.go(delta);
|
|
1404
|
+
return promise;
|
|
1133
1405
|
}
|
|
1134
1406
|
|
|
1135
1407
|
/**
|
|
@@ -1143,6 +1415,68 @@ export function createRouter(options = {}) {
|
|
|
1143
1415
|
return prev;
|
|
1144
1416
|
}
|
|
1145
1417
|
|
|
1418
|
+
/**
|
|
1419
|
+
* Issue #72: Subscribe to loading state changes
|
|
1420
|
+
* @param {function} callback - Called with (loading: boolean) when loading state changes
|
|
1421
|
+
* @returns {function} Unsubscribe function
|
|
1422
|
+
*/
|
|
1423
|
+
function onLoadingChange(callback) {
|
|
1424
|
+
const dispose = effect(() => {
|
|
1425
|
+
callback(isLoading.get());
|
|
1426
|
+
});
|
|
1427
|
+
loadingListeners.push(dispose);
|
|
1428
|
+
return () => {
|
|
1429
|
+
dispose();
|
|
1430
|
+
const idx = loadingListeners.indexOf(dispose);
|
|
1431
|
+
if (idx > -1) loadingListeners.splice(idx, 1);
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/**
|
|
1436
|
+
* Issue #66: Register a callback to run before leaving the current route
|
|
1437
|
+
* If callback returns false, navigation is blocked
|
|
1438
|
+
* @param {function} callback - (to, from) => boolean|void
|
|
1439
|
+
* @returns {function} Unsubscribe function
|
|
1440
|
+
*/
|
|
1441
|
+
function registerBeforeLeave(callback) {
|
|
1442
|
+
const path = currentPath.peek();
|
|
1443
|
+
if (!beforeLeaveHooks.has(path)) {
|
|
1444
|
+
beforeLeaveHooks.set(path, []);
|
|
1445
|
+
}
|
|
1446
|
+
beforeLeaveHooks.get(path).push(callback);
|
|
1447
|
+
|
|
1448
|
+
return () => {
|
|
1449
|
+
const hooks = beforeLeaveHooks.get(path);
|
|
1450
|
+
if (hooks) {
|
|
1451
|
+
const idx = hooks.indexOf(callback);
|
|
1452
|
+
if (idx > -1) hooks.splice(idx, 1);
|
|
1453
|
+
if (hooks.length === 0) beforeLeaveHooks.delete(path);
|
|
1454
|
+
}
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* Issue #66: Register a callback to run after entering the current route
|
|
1460
|
+
* @param {function} callback - (to) => void
|
|
1461
|
+
* @returns {function} Unsubscribe function
|
|
1462
|
+
*/
|
|
1463
|
+
function registerAfterEnter(callback) {
|
|
1464
|
+
const path = currentPath.peek();
|
|
1465
|
+
if (!afterEnterHooks.has(path)) {
|
|
1466
|
+
afterEnterHooks.set(path, []);
|
|
1467
|
+
}
|
|
1468
|
+
afterEnterHooks.get(path).push(callback);
|
|
1469
|
+
|
|
1470
|
+
return () => {
|
|
1471
|
+
const hooks = afterEnterHooks.get(path);
|
|
1472
|
+
if (hooks) {
|
|
1473
|
+
const idx = hooks.indexOf(callback);
|
|
1474
|
+
if (idx > -1) hooks.splice(idx, 1);
|
|
1475
|
+
if (hooks.length === 0) afterEnterHooks.delete(path);
|
|
1476
|
+
}
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1146
1480
|
/**
|
|
1147
1481
|
* Router instance with reactive state and navigation methods.
|
|
1148
1482
|
*
|
|
@@ -1195,15 +1529,26 @@ export function createRouter(options = {}) {
|
|
|
1195
1529
|
afterEach,
|
|
1196
1530
|
setErrorHandler,
|
|
1197
1531
|
|
|
1532
|
+
// Issue #66: Route lifecycle hooks
|
|
1533
|
+
onBeforeLeave: registerBeforeLeave,
|
|
1534
|
+
onAfterEnter: registerAfterEnter,
|
|
1535
|
+
|
|
1536
|
+
// Issue #72: Loading state listener
|
|
1537
|
+
onLoadingChange,
|
|
1538
|
+
|
|
1198
1539
|
// Route inspection
|
|
1199
1540
|
isActive,
|
|
1200
1541
|
getMatchedRoutes,
|
|
1201
1542
|
|
|
1202
1543
|
// Utility functions
|
|
1203
1544
|
matchRoute,
|
|
1204
|
-
parseQuery
|
|
1545
|
+
parseQuery,
|
|
1546
|
+
buildQueryString
|
|
1205
1547
|
};
|
|
1206
1548
|
|
|
1549
|
+
// Set as active router for standalone exports (onBeforeLeave, onAfterEnter)
|
|
1550
|
+
_activeRouter = router;
|
|
1551
|
+
|
|
1207
1552
|
return router;
|
|
1208
1553
|
}
|
|
1209
1554
|
|
|
@@ -1217,11 +1562,44 @@ export function simpleRouter(routes, target = '#app') {
|
|
|
1217
1562
|
return router;
|
|
1218
1563
|
}
|
|
1219
1564
|
|
|
1565
|
+
/**
|
|
1566
|
+
* Register a callback to run before leaving the current route
|
|
1567
|
+
* Must be called within a route handler context
|
|
1568
|
+
* @param {function} callback - (to, from) => boolean|void — return false to block
|
|
1569
|
+
* @returns {function} Unsubscribe function
|
|
1570
|
+
*/
|
|
1571
|
+
export function onBeforeLeave(callback) {
|
|
1572
|
+
if (!_activeRouter) {
|
|
1573
|
+
log.warn('onBeforeLeave() called outside of a router context');
|
|
1574
|
+
return () => {};
|
|
1575
|
+
}
|
|
1576
|
+
return _activeRouter.onBeforeLeave(callback);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/**
|
|
1580
|
+
* Register a callback to run after entering the current route
|
|
1581
|
+
* Must be called within a route handler context
|
|
1582
|
+
* @param {function} callback - (to) => void
|
|
1583
|
+
* @returns {function} Unsubscribe function
|
|
1584
|
+
*/
|
|
1585
|
+
export function onAfterEnter(callback) {
|
|
1586
|
+
if (!_activeRouter) {
|
|
1587
|
+
log.warn('onAfterEnter() called outside of a router context');
|
|
1588
|
+
return () => {};
|
|
1589
|
+
}
|
|
1590
|
+
return _activeRouter.onAfterEnter(callback);
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
export { buildQueryString, parseQuery, matchRoute };
|
|
1594
|
+
|
|
1220
1595
|
export default {
|
|
1221
1596
|
createRouter,
|
|
1222
1597
|
simpleRouter,
|
|
1223
1598
|
lazy,
|
|
1224
1599
|
preload,
|
|
1225
1600
|
matchRoute,
|
|
1226
|
-
parseQuery
|
|
1601
|
+
parseQuery,
|
|
1602
|
+
buildQueryString,
|
|
1603
|
+
onBeforeLeave,
|
|
1604
|
+
onAfterEnter
|
|
1227
1605
|
};
|