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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/runtime/router.js +548 -170
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulse-js-framework",
3
- "version": "1.8.1",
3
+ "version": "1.8.3",
4
4
  "description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
5
5
  "type": "module",
6
6
  "sideEffects": false,
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
- // Find matching route first (needed for beforeEnter guard)
659
- const match = findRoute(path);
660
-
661
- // Build full path with query
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
- // Handle redirect
669
- if (match?.route?.redirect) {
670
- const redirectPath = typeof match.route.redirect === 'function'
671
- ? match.route.redirect({ params: match.params, query })
672
- : match.route.redirect;
673
- return navigate(redirectPath, { replace: true });
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
- // Create navigation context for guards
677
- const from = {
678
- path: currentPath.peek(),
679
- params: currentParams.peek(),
680
- query: currentQuery.peek(),
681
- meta: currentMeta.peek()
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
- const to = {
685
- path,
686
- params: match?.params || {},
687
- query: parseQuery(queryString),
688
- meta: match?.route?.meta || {}
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
- // Run middleware if configured
692
- if (middleware.length > 0) {
693
- const runMiddleware = createMiddlewareRunner(middleware);
694
- const middlewareResult = await runMiddleware({ to, from });
695
- if (middlewareResult.aborted) {
696
- return false;
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
- if (middlewareResult.redirectPath) {
699
- return navigate(middlewareResult.redirectPath, { replace: true });
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
- // Run global beforeEach hooks
706
- for (const hook of beforeHooks) {
707
- const result = await hook(to, from);
708
- if (result === false) return false;
709
- if (typeof result === 'string') {
710
- return navigate(result, options);
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
- // Run per-route beforeEnter guard
715
- if (match?.route?.beforeEnter) {
716
- const result = await match.route.beforeEnter(to, from);
717
- if (result === false) return false;
718
- if (typeof result === 'string') {
719
- return navigate(result, options);
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
- // Run beforeResolve hooks (after per-route guards)
724
- for (const hook of resolveHooks) {
725
- const result = await hook(to, from);
726
- if (result === false) return false;
727
- if (typeof result === 'string') {
728
- return navigate(result, options);
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
- // Save scroll position before leaving
733
- const currentFullPath = currentPath.peek();
734
- if (currentFullPath) {
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
- // Update URL
743
- const url = mode === 'hash' ? `#${fullPath}` : `${base}${fullPath}`;
744
- const historyState = { path: fullPath, ...(state || {}) };
892
+ if (replace) {
893
+ window.history.replaceState(historyState, '', url);
894
+ } else {
895
+ window.history.pushState(historyState, '', url);
896
+ }
745
897
 
746
- if (replace) {
747
- window.history.replaceState(historyState, '', url);
748
- } else {
749
- window.history.pushState(historyState, '', url);
750
- }
898
+ // Update reactive state
899
+ await updateRoute(path, parsedQuery, match);
751
900
 
752
- // Update reactive state
753
- await updateRoute(path, parseQuery(queryString), match);
901
+ // Handle scroll behavior
902
+ handleScroll(to, from, scrollPositions.get(path));
754
903
 
755
- // Handle scroll behavior
756
- handleScroll(to, from, scrollPositions.get(path));
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
- return true;
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
- if (route && route.handler) {
939
- // Create context for the route handler
940
- const ctx = {
941
- params,
942
- query,
943
- path: currentPath.peek(),
944
- navigate,
945
- router
946
- };
947
-
948
- // Helper to handle errors
949
- const handleError = (error) => {
950
- routeError.set(error);
951
- log.error('Route component error:', error);
952
-
953
- if (onRouteError) {
954
- try {
955
- const errorView = onRouteError(error, ctx);
956
- if (errorView instanceof Node) {
957
- container.replaceChildren(errorView);
958
- currentView = errorView;
959
- return true;
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
- const errorEl = el('div.route-error', [
967
- el('h2', 'Route Error'),
968
- el('p', error.message || 'Failed to load route component')
969
- ]);
970
- container.replaceChildren(errorEl);
971
- currentView = errorEl;
972
- return true;
973
- };
974
-
975
- // Call handler and render result (with error handling)
976
- let result;
977
- try {
978
- result = typeof route.handler === 'function'
979
- ? route.handler(ctx)
980
- : route.handler;
981
- } catch (error) {
982
- handleError(error);
983
- return;
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
- if (result instanceof Node) {
987
- container.appendChild(result);
988
- currentView = result;
989
- routeError.set(null);
990
- } else if (result && typeof result.then === 'function') {
991
- // Async component
992
- isLoading.set(true);
993
- routeError.set(null);
994
- result
995
- .then(component => {
996
- isLoading.set(false);
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
- .catch(error => {
1004
- isLoading.set(false);
1005
- handleError(error);
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
- * Equivalent to browser back button
1104
- * @returns {void}
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
- * Equivalent to browser forward button
1115
- * @returns {void}
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 {void}
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
  };