@idealyst/navigation 1.0.96 → 1.0.98

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idealyst/navigation",
3
- "version": "1.0.96",
3
+ "version": "1.0.98",
4
4
  "description": "Cross-platform navigation library for React and React Native",
5
5
  "readme": "README.md",
6
6
  "main": "src/index.ts",
@@ -43,8 +43,8 @@
43
43
  "publish:npm": "npm publish"
44
44
  },
45
45
  "peerDependencies": {
46
- "@idealyst/components": "^1.0.96",
47
- "@idealyst/theme": "^1.0.96",
46
+ "@idealyst/components": "^1.0.98",
47
+ "@idealyst/theme": "^1.0.98",
48
48
  "@react-navigation/bottom-tabs": ">=7.0.0",
49
49
  "@react-navigation/drawer": ">=7.0.0",
50
50
  "@react-navigation/native": ">=7.0.0",
@@ -60,10 +60,10 @@
60
60
  "react-router-dom": ">=6.0.0"
61
61
  },
62
62
  "devDependencies": {
63
- "@idealyst/components": "^1.0.96",
63
+ "@idealyst/components": "^1.0.98",
64
64
  "@idealyst/datagrid": "^1.0.93",
65
65
  "@idealyst/datepicker": "^1.0.93",
66
- "@idealyst/theme": "^1.0.96",
66
+ "@idealyst/theme": "^1.0.98",
67
67
  "@types/react": "^19.1.8",
68
68
  "@types/react-dom": "^19.1.6",
69
69
  "react": "^19.1.0",
@@ -1,18 +1,93 @@
1
- import React, { createContext, memo, use, useContext, useMemo } from 'react';
1
+ import React, { createContext, memo, useContext, useMemo } from 'react';
2
2
  import { NavigateParams, NavigatorProviderProps, NavigatorContextValue } from './types';
3
- import { useNavigation, useNavigationState, DarkTheme, DefaultTheme, NavigationContainer, useRoute } from '@react-navigation/native';
4
- import { buildNavigator } from '../routing';
3
+ import { useNavigation, DarkTheme, DefaultTheme, NavigationContainer, CommonActions } from '@react-navigation/native';
4
+ import { buildNavigator, NavigatorParam, NOT_FOUND_SCREEN_NAME } from '../routing';
5
5
  import { useUnistyles } from 'react-native-unistyles';
6
6
 
7
- const NavigatorContext = createContext<NavigatorContextValue>({
8
- route: undefined,
9
- navigate: () => {},
10
- });
7
+ const NavigatorContext = createContext<NavigatorContextValue>(null!);
11
8
 
12
- const DrawerNavigatorContext = createContext<NavigatorContextValue>({
13
- route: undefined,
14
- navigate: () => {},
15
- });
9
+ const DrawerNavigatorContext = createContext<NavigatorContextValue>(null!);
10
+
11
+ /**
12
+ * Find the nearest invalid route handler by matching path prefixes and bubbling up.
13
+ * Returns the handler from the deepest matching navigator, or undefined if none found.
14
+ */
15
+ function findInvalidRouteHandler(
16
+ route: NavigatorParam,
17
+ invalidPath: string
18
+ ): ((path: string) => NavigateParams | undefined) | undefined {
19
+ const pathSegments = invalidPath.split('/').filter(Boolean);
20
+
21
+ // Recursively search for handlers, collecting them from root to deepest match
22
+ function collectHandlers(
23
+ currentRoute: NavigatorParam,
24
+ segmentIndex: number,
25
+ prefix: string
26
+ ): Array<(path: string) => NavigateParams | undefined> {
27
+ const handlers: Array<(path: string) => NavigateParams | undefined> = [];
28
+
29
+ // Add this navigator's handler if it exists
30
+ if (currentRoute.onInvalidRoute) {
31
+ handlers.push(currentRoute.onInvalidRoute);
32
+ }
33
+
34
+ // Check if any child route matches the next segments
35
+ if (currentRoute.routes && segmentIndex < pathSegments.length) {
36
+ for (const childRoute of currentRoute.routes) {
37
+ if (childRoute.type !== 'navigator') continue;
38
+
39
+ const childSegments = childRoute.path.split('/').filter(Boolean);
40
+
41
+ // Check if child path matches the next path segments
42
+ let matches = true;
43
+ for (let i = 0; i < childSegments.length && segmentIndex + i < pathSegments.length; i++) {
44
+ const childSeg = childSegments[i];
45
+ const pathSeg = pathSegments[segmentIndex + i];
46
+
47
+ if (!childSeg.startsWith(':') && childSeg !== pathSeg) {
48
+ matches = false;
49
+ break;
50
+ }
51
+ }
52
+
53
+ if (matches && childSegments.length > 0) {
54
+ // Recurse into matching child navigator
55
+ const childPrefix = `${prefix}/${childRoute.path}`.replace(/\/+/g, '/');
56
+ const childHandlers = collectHandlers(
57
+ childRoute as NavigatorParam,
58
+ segmentIndex + childSegments.length,
59
+ childPrefix
60
+ );
61
+ handlers.push(...childHandlers);
62
+ }
63
+ }
64
+ }
65
+
66
+ return handlers;
67
+ }
68
+
69
+ const handlers = collectHandlers(route, 0, '');
70
+
71
+ // Return the deepest handler (last in the array)
72
+ return handlers.length > 0 ? handlers[handlers.length - 1] : undefined;
73
+ }
74
+
75
+ /**
76
+ * Check if any navigator in the tree has a notFoundComponent configured
77
+ */
78
+ function hasNotFoundComponent(route: NavigatorParam): boolean {
79
+ if (route.notFoundComponent) return true;
80
+
81
+ if (route.routes) {
82
+ for (const child of route.routes) {
83
+ if (child.type === 'navigator' && hasNotFoundComponent(child as NavigatorParam)) {
84
+ return true;
85
+ }
86
+ }
87
+ }
88
+
89
+ return false;
90
+ }
16
91
 
17
92
  // Utility function to parse path with parameters and find matching route
18
93
  const parseParameterizedPath = (path: string, rootRoute: any): { routeName: string, params: Record<string, string> } | null => {
@@ -84,15 +159,56 @@ const UnwrappedNavigatorProvider = ({ route }: NavigatorProviderProps) => {
84
159
 
85
160
  const navigation = useNavigation();
86
161
 
87
- const navigate = (params: NavigateParams) => {
162
+ const navigate = (params: NavigateParams, _redirectCount = 0) => {
163
+ // Prevent infinite redirect loops
164
+ if (_redirectCount > 10) {
165
+ console.error('Navigation: Maximum redirect count exceeded. Check onInvalidRoute handlers.');
166
+ return;
167
+ }
168
+
88
169
  // Parse parameterized path for mobile
89
170
  const parsed = parseParameterizedPath(params.path, route);
90
- if (parsed) {
171
+
172
+ if (!parsed) {
173
+ // Invalid route - try to find a handler
174
+ const handler = findInvalidRouteHandler(route, params.path);
175
+ if (handler) {
176
+ const redirectParams = handler(params.path);
177
+ if (redirectParams) {
178
+ // Handler returned NavigateParams - redirect
179
+ return navigate(
180
+ { ...redirectParams, replace: true },
181
+ _redirectCount + 1
182
+ );
183
+ }
184
+ // Handler returned undefined - fall through to 404 screen
185
+ }
186
+
187
+ // Navigate to 404 screen if configured
188
+ if (route.notFoundComponent) {
189
+ navigation.navigate(NOT_FOUND_SCREEN_NAME as never, {
190
+ path: params.path,
191
+ params: params.vars
192
+ } as never);
193
+ return;
194
+ }
195
+
196
+ // No handler and no 404 screen - log warning
197
+ console.warn(`Navigation: Invalid route "${params.path}" and no handler or notFoundComponent configured.`);
198
+ return;
199
+ }
200
+
201
+ if (params.replace) {
202
+ // Use CommonActions.reset to replace the current route
203
+ navigation.dispatch(
204
+ CommonActions.reset({
205
+ index: 0,
206
+ routes: [{ name: parsed.routeName, params: parsed.params }],
207
+ })
208
+ );
209
+ } else {
91
210
  // Navigate to the pattern route with extracted parameters
92
211
  navigation.navigate(parsed.routeName as never, parsed.params as never);
93
- } else {
94
- // Fallback to direct navigation
95
- navigation.navigate(params.path as never, params.vars as never);
96
212
  }
97
213
  };
98
214
 
@@ -125,14 +241,56 @@ const NavigatorProvider = ({ route }: NavigatorProviderProps) => {
125
241
 
126
242
  const DrawerNavigatorProvider = ({ navigation, route, children }: { navigation: any, route: any, children: React.ReactNode }) => {
127
243
 
128
- const navigate = (params: NavigateParams) => {
244
+ const navigate = (params: NavigateParams, _redirectCount = 0) => {
245
+ // Prevent infinite redirect loops
246
+ if (_redirectCount > 10) {
247
+ console.error('Navigation: Maximum redirect count exceeded. Check onInvalidRoute handlers.');
248
+ return;
249
+ }
250
+
129
251
  // Parse parameterized path for mobile
130
252
  const parsed = parseParameterizedPath(params.path, route);
131
- if (parsed) {
253
+
254
+ if (!parsed) {
255
+ // Invalid route - try to find a handler
256
+ const handler = findInvalidRouteHandler(route, params.path);
257
+ if (handler) {
258
+ const redirectParams = handler(params.path);
259
+ if (redirectParams) {
260
+ // Handler returned NavigateParams - redirect
261
+ return navigate(
262
+ { ...redirectParams, replace: true },
263
+ _redirectCount + 1
264
+ );
265
+ }
266
+ // Handler returned undefined - fall through to 404 screen
267
+ }
268
+
269
+ // Navigate to 404 screen if configured
270
+ if (route.notFoundComponent) {
271
+ navigation.navigate(NOT_FOUND_SCREEN_NAME as never, {
272
+ path: params.path,
273
+ params: params.vars
274
+ } as never);
275
+ return;
276
+ }
277
+
278
+ // No handler and no 404 screen - log warning
279
+ console.warn(`Navigation: Invalid route "${params.path}" and no handler or notFoundComponent configured.`);
280
+ return;
281
+ }
282
+
283
+ if (params.replace) {
284
+ // Use CommonActions.reset to replace the current route
285
+ navigation.dispatch(
286
+ CommonActions.reset({
287
+ index: 0,
288
+ routes: [{ name: parsed.routeName, params: parsed.params }],
289
+ })
290
+ );
291
+ } else {
132
292
  // Navigate to the pattern route with extracted parameters
133
293
  navigation.navigate(parsed.routeName as never, parsed.params as never);
134
- } else {
135
- // Fallback to direct navigation
136
294
  }
137
295
  };
138
296
 
@@ -1,45 +1,224 @@
1
1
  import React, { createContext, memo, useContext, useMemo } from 'react';
2
2
  import { useNavigate, useParams } from '../router';
3
3
  import { NavigateParams, NavigatorProviderProps, NavigatorContextValue } from './types';
4
- import { buildNavigator } from '../routing';
4
+ import { buildNavigator, NavigatorParam } from '../routing';
5
5
 
6
6
  const NavigatorContext = createContext<NavigatorContextValue>({
7
7
  navigate: () => {},
8
8
  route: undefined,
9
9
  });
10
10
 
11
+ /**
12
+ * Normalize a path and substitute variables
13
+ */
14
+ function normalizePath(path: string, vars?: Record<string, string>): string {
15
+ let normalizedPath = path;
16
+
17
+ // Convert empty string to '/'
18
+ if (normalizedPath === '' || normalizedPath === '/') {
19
+ normalizedPath = '/';
20
+ } else if (!normalizedPath.startsWith('/')) {
21
+ normalizedPath = `/${normalizedPath}`;
22
+ }
23
+
24
+ // Substitute variables in the path if provided
25
+ if (vars) {
26
+ Object.entries(vars).forEach(([key, value]) => {
27
+ normalizedPath = normalizedPath.replace(`:${key}`, value);
28
+ });
29
+ }
30
+
31
+ return normalizedPath;
32
+ }
33
+
34
+ /**
35
+ * Build a list of valid route patterns from the route tree
36
+ */
37
+ function buildValidPatterns(route: NavigatorParam, prefix = ''): string[] {
38
+ const patterns: string[] = [];
39
+
40
+ if (!route.routes) return patterns;
41
+
42
+ for (const childRoute of route.routes) {
43
+ const childPath = childRoute.path.startsWith('/')
44
+ ? childRoute.path
45
+ : `${prefix}/${childRoute.path}`.replace(/\/+/g, '/');
46
+
47
+ // Add the pattern (keeping :param placeholders)
48
+ if (childPath) {
49
+ patterns.push(childPath === '' ? '/' : childPath);
50
+ }
51
+
52
+ // Recursively add nested routes
53
+ if (childRoute.type === 'navigator') {
54
+ patterns.push(...buildValidPatterns(childRoute as NavigatorParam, childPath));
55
+ }
56
+ }
57
+
58
+ // Also add the root pattern
59
+ if (prefix === '' || prefix === '/') {
60
+ patterns.push('/');
61
+ }
62
+
63
+ return patterns;
64
+ }
65
+
66
+ /**
67
+ * Check if a path matches any of the valid route patterns
68
+ */
69
+ function isValidRoute(path: string, patterns: string[]): boolean {
70
+ const pathSegments = path.split('/').filter(Boolean);
71
+
72
+ for (const pattern of patterns) {
73
+ const patternSegments = pattern.split('/').filter(Boolean);
74
+
75
+ // Check for root path match
76
+ if (path === '/' && (pattern === '/' || pattern === '')) {
77
+ return true;
78
+ }
79
+
80
+ // Length must match
81
+ if (pathSegments.length !== patternSegments.length) {
82
+ continue;
83
+ }
84
+
85
+ let isMatch = true;
86
+ for (let i = 0; i < patternSegments.length; i++) {
87
+ const patternSeg = patternSegments[i];
88
+ const pathSeg = pathSegments[i];
89
+
90
+ // Parameter segments match anything
91
+ if (patternSeg.startsWith(':')) {
92
+ continue;
93
+ }
94
+
95
+ // Literal segments must match exactly
96
+ if (patternSeg !== pathSeg) {
97
+ isMatch = false;
98
+ break;
99
+ }
100
+ }
101
+
102
+ if (isMatch) {
103
+ return true;
104
+ }
105
+ }
106
+
107
+ return false;
108
+ }
109
+
110
+ /**
111
+ * Find the nearest invalid route handler by matching path prefixes and bubbling up.
112
+ * Returns the handler from the deepest matching navigator, or undefined if none found.
113
+ */
114
+ function findInvalidRouteHandler(
115
+ route: NavigatorParam,
116
+ invalidPath: string
117
+ ): ((path: string) => NavigateParams | undefined) | undefined {
118
+ const pathSegments = invalidPath.split('/').filter(Boolean);
119
+
120
+ // Recursively search for handlers, collecting them from root to deepest match
121
+ function collectHandlers(
122
+ currentRoute: NavigatorParam,
123
+ segmentIndex: number,
124
+ prefix: string
125
+ ): Array<(path: string) => NavigateParams | undefined> {
126
+ const handlers: Array<(path: string) => NavigateParams | undefined> = [];
127
+
128
+ // Add this navigator's handler if it exists
129
+ if (currentRoute.onInvalidRoute) {
130
+ handlers.push(currentRoute.onInvalidRoute);
131
+ }
132
+
133
+ // Check if any child route matches the next segments
134
+ if (currentRoute.routes && segmentIndex < pathSegments.length) {
135
+ for (const childRoute of currentRoute.routes) {
136
+ if (childRoute.type !== 'navigator') continue;
137
+
138
+ const childSegments = childRoute.path.split('/').filter(Boolean);
139
+
140
+ // Check if child path matches the next path segments
141
+ let matches = true;
142
+ for (let i = 0; i < childSegments.length && segmentIndex + i < pathSegments.length; i++) {
143
+ const childSeg = childSegments[i];
144
+ const pathSeg = pathSegments[segmentIndex + i];
145
+
146
+ if (!childSeg.startsWith(':') && childSeg !== pathSeg) {
147
+ matches = false;
148
+ break;
149
+ }
150
+ }
151
+
152
+ if (matches && childSegments.length > 0) {
153
+ // Recurse into matching child navigator
154
+ const childPrefix = `${prefix}/${childRoute.path}`.replace(/\/+/g, '/');
155
+ const childHandlers = collectHandlers(
156
+ childRoute as NavigatorParam,
157
+ segmentIndex + childSegments.length,
158
+ childPrefix
159
+ );
160
+ handlers.push(...childHandlers);
161
+ }
162
+ }
163
+ }
164
+
165
+ return handlers;
166
+ }
167
+
168
+ const handlers = collectHandlers(route, 0, '');
169
+
170
+ // Return the deepest handler (last in the array)
171
+ return handlers.length > 0 ? handlers[handlers.length - 1] : undefined;
172
+ }
173
+
11
174
  export const NavigatorProvider = ({
12
175
  route,
13
176
  }: NavigatorProviderProps) => {
14
- const reactRouterNavigate = useNavigate()
15
-
16
- const navigateFunction = (params: NavigateParams) => {
177
+ const reactRouterNavigate = useNavigate();
178
+
179
+ // Memoize the list of valid route patterns
180
+ const validPatterns = useMemo(() => buildValidPatterns(route), [route]);
181
+
182
+ const navigateFunction = (params: NavigateParams, _redirectCount = 0) => {
183
+ // Prevent infinite redirect loops
184
+ if (_redirectCount > 10) {
185
+ console.error('Navigation: Maximum redirect count exceeded. Check onInvalidRoute handlers.');
186
+ return;
187
+ }
188
+
17
189
  if (params.path) {
18
- // Normalize path - convert empty string to '/'
19
- let path = params.path
20
- if (path === '' || path === '/') {
21
- path = '/'
22
- } else if (!path.startsWith('/')) {
23
- path = `/${path}`
24
- }
25
-
26
- // Substitute variables in the path if provided
27
- if (params.vars) {
28
- Object.entries(params.vars).forEach(([key, value]) => {
29
- path = path.replace(`:${key}`, value);
30
- });
190
+ const path = normalizePath(params.path, params.vars);
191
+
192
+ // Check if route is valid
193
+ if (!isValidRoute(path, validPatterns)) {
194
+ // Try to find a handler for the invalid route
195
+ const handler = findInvalidRouteHandler(route, path);
196
+ if (handler) {
197
+ const redirectParams = handler(path);
198
+ if (redirectParams) {
199
+ // Handler returned NavigateParams - redirect
200
+ return navigateFunction(
201
+ { ...redirectParams, replace: true },
202
+ _redirectCount + 1
203
+ );
204
+ }
205
+ // Handler returned undefined - let React Router show 404 via catch-all
206
+ }
207
+ // No handler or handler returned undefined
208
+ // Navigate anyway - React Router's catch-all will show notFoundComponent
209
+ // If no notFoundComponent configured, React Router will render nothing
31
210
  }
32
-
33
- // Use React Router's navigate function
34
- reactRouterNavigate(path);
211
+
212
+ // Use React Router's navigate function with replace option
213
+ reactRouterNavigate(path, { replace: params.replace });
35
214
  }
36
215
  };
37
-
216
+
38
217
  const RouteComponent = useMemo(() => {
39
218
  // Memoize the router to prevent unnecessary re-renders
40
219
  return memo(buildNavigator(route));
41
220
  }, [route]);
42
-
221
+
43
222
  return (
44
223
  <NavigatorContext.Provider value={{
45
224
  route,
@@ -6,6 +6,11 @@ import { NavigatorParam } from "../routing";
6
6
  export type NavigateParams = {
7
7
  path: string;
8
8
  vars?: Record<string, string>;
9
+ /**
10
+ * If true, replaces the current history entry instead of pushing a new one.
11
+ * On web, this uses history.replace(). On native, this resets the navigation state.
12
+ */
13
+ replace?: boolean;
9
14
  };
10
15
 
11
16
  export type NavigatorProviderProps = {
@@ -2,11 +2,151 @@ import React from 'react';
2
2
  import { AvatarExamples, BadgeExamples, ButtonExamples, CardExamples, CheckboxExamples, DialogExamples, DividerExamples, IconExamples, InputExamples, LinkExamples, PopoverExamples, ScreenExamples, SelectExamples, SliderExamples, SVGImageExamples, TextExamples, ViewExamples, ThemeExtensionExamples, SwitchExamples, RadioButtonExamples, ProgressExamples, TextAreaExamples, TabBarExamples, TooltipExamples, AccordionExamples, ListExamples, TableExamples, MenuExamples, ImageExamples, VideoExamples, AlertExamples, SkeletonExamples, ChipExamples, BreadcrumbExamples } from '@idealyst/components/examples';
3
3
  import { DataGridShowcase } from '@idealyst/datagrid/examples';
4
4
  import { DatePickerExamples } from '@idealyst/datepicker/examples';
5
- import { Text, View, Card, Screen } from '@idealyst/components';
6
- import { NavigatorParam, RouteParam } from '../routing';
5
+ import { Text, View, Card, Screen, Icon, Button } from '@idealyst/components';
6
+ import { NavigatorParam, RouteParam, NotFoundComponentProps } from '../routing';
7
7
  import { ExampleWebLayout } from './ExampleWebLayout';
8
8
  import ExampleSidebar from './ExampleSidebar';
9
9
  import HeaderRight from './HeaderRight';
10
+ import { useNavigator } from '../context';
11
+
12
+ /**
13
+ * Global 404 Not Found screen - shown for invalid routes at the root level
14
+ */
15
+ const NotFoundScreen = ({ path, params }: NotFoundComponentProps) => {
16
+ const { navigate } = useNavigator();
17
+
18
+ return (
19
+ <Screen>
20
+ <View spacing='lg' padding={12} style={{ alignItems: 'center', justifyContent: 'center', flex: 1 }}>
21
+ <Icon name="alert-circle-outline" size={64} color="red" />
22
+ <Text size="xl" weight="bold">
23
+ Page Not Found
24
+ </Text>
25
+ <Text size="md" color="secondary">
26
+ The page you're looking for doesn't exist.
27
+ </Text>
28
+ <Card style={{ marginTop: 16, padding: 16 }}>
29
+ <View spacing="sm">
30
+ <Text size="sm" weight="semibold">Attempted path:</Text>
31
+ <Text size="sm" color="secondary">{path}</Text>
32
+ {params && Object.keys(params).length > 0 && (
33
+ <>
34
+ <Text size="sm" weight="semibold" style={{ marginTop: 8 }}>Params:</Text>
35
+ <Text size="sm" color="secondary">{JSON.stringify(params, null, 2)}</Text>
36
+ </>
37
+ )}
38
+ </View>
39
+ </Card>
40
+ <Button
41
+ style={{ marginTop: 24 }}
42
+ onPress={() => navigate({ path: '/', replace: true })}
43
+ >
44
+ Go Home
45
+ </Button>
46
+ </View>
47
+ </Screen>
48
+ );
49
+ };
50
+
51
+ /**
52
+ * Settings-specific 404 screen - a simpler, more minimal style
53
+ */
54
+ const SettingsNotFoundScreen = ({ path, params }: NotFoundComponentProps) => {
55
+ const { navigate } = useNavigator();
56
+
57
+ return (
58
+ <Screen>
59
+ <View spacing='md' padding={12} style={{ alignItems: 'center', justifyContent: 'center', flex: 1, backgroundColor: '#f5f5f5' }}>
60
+ <Icon name="cog-off-outline" size={48} color="orange" />
61
+ <Text size="lg" weight="semibold">
62
+ Settings Page Not Found
63
+ </Text>
64
+ <Text size="sm" color="secondary" style={{ textAlign: 'center', maxWidth: 300 }}>
65
+ The settings page "{path}" doesn't exist. You've been redirected here.
66
+ </Text>
67
+ <View direction="row" spacing="md" style={{ marginTop: 16 }}>
68
+ <Button
69
+ type="outlined"
70
+ size="sm"
71
+ onPress={() => navigate({ path: '/settings', replace: true })}
72
+ >
73
+ Settings Home
74
+ </Button>
75
+ <Button
76
+ size="sm"
77
+ onPress={() => navigate({ path: '/', replace: true })}
78
+ >
79
+ Go Home
80
+ </Button>
81
+ </View>
82
+ </View>
83
+ </Screen>
84
+ );
85
+ };
86
+
87
+ // Settings section screens
88
+ const SettingsHomeScreen = () => (
89
+ <Screen>
90
+ <View spacing='lg' padding={12}>
91
+ <Text size="xl" weight="bold">Settings</Text>
92
+ <Text size="md" color="secondary">Manage your application settings</Text>
93
+ <Card style={{ marginTop: 16, padding: 16 }}>
94
+ <View spacing="sm">
95
+ <Text size="sm">Navigate to:</Text>
96
+ <Text size="sm" color="secondary">• /settings/general - General settings</Text>
97
+ <Text size="sm" color="secondary">• /settings/account - Account settings</Text>
98
+ <Text size="sm" color="secondary">• /settings/invalid - Test 404 (will show SettingsNotFoundScreen)</Text>
99
+ <Text size="sm" color="secondary">• /settings/redirect-me - Test redirect handler</Text>
100
+ </View>
101
+ </Card>
102
+ </View>
103
+ </Screen>
104
+ );
105
+
106
+ const GeneralSettingsScreen = () => (
107
+ <Screen>
108
+ <View spacing='lg' padding={12}>
109
+ <Text size="xl" weight="bold">General Settings</Text>
110
+ <Text size="md" color="secondary">Configure general app preferences</Text>
111
+ </View>
112
+ </Screen>
113
+ );
114
+
115
+ const AccountSettingsScreen = () => (
116
+ <Screen>
117
+ <View spacing='lg' padding={12}>
118
+ <Text size="xl" weight="bold">Account Settings</Text>
119
+ <Text size="md" color="secondary">Manage your account</Text>
120
+ </View>
121
+ </Screen>
122
+ );
123
+
124
+ /**
125
+ * Nested Settings Navigator with its own error handling:
126
+ * - onInvalidRoute: Redirects /settings/redirect-* to /settings/general
127
+ * - notFoundComponent: Shows SettingsNotFoundScreen for other invalid routes
128
+ */
129
+ const SettingsNavigator: NavigatorParam = {
130
+ path: "settings",
131
+ type: 'navigator',
132
+ layout: 'stack',
133
+ notFoundComponent: SettingsNotFoundScreen,
134
+ onInvalidRoute: (invalidPath) => {
135
+ // Example: Redirect old/deprecated paths to the general settings
136
+ if (invalidPath.includes('redirect')) {
137
+ console.log(`[Settings] Redirecting "${invalidPath}" to /settings/general`);
138
+ return { path: '/settings/general', replace: true };
139
+ }
140
+ // Return undefined to show the notFoundComponent instead
141
+ console.log(`[Settings] Showing 404 for "${invalidPath}"`);
142
+ return undefined;
143
+ },
144
+ routes: [
145
+ { path: "", type: 'screen', component: SettingsHomeScreen, options: { title: "Settings" } },
146
+ { path: "general", type: 'screen', component: GeneralSettingsScreen, options: { title: "General" } },
147
+ { path: "account", type: 'screen', component: AccountSettingsScreen, options: { title: "Account" } },
148
+ ],
149
+ };
10
150
 
11
151
  const HomeScreen = () => {
12
152
  return (
@@ -65,11 +205,14 @@ const ExampleNavigationRouter: NavigatorParam = {
65
205
  layout: 'drawer',
66
206
  sidebarComponent: ExampleSidebar,
67
207
  layoutComponent: ExampleWebLayout,
208
+ notFoundComponent: NotFoundScreen,
68
209
  options: {
69
210
  headerRight: HeaderRight,
70
211
  },
71
212
  routes: [
72
213
  { path: "/", type: 'screen', component: HomeScreen, options: { title: "Home" } },
214
+ // Nested settings navigator with its own 404 handling
215
+ SettingsNavigator,
73
216
  { path: "avatar", type: 'screen', component: AvatarExamples, options: { title: "Avatar" } },
74
217
  { path: "badge", type: 'screen', component: BadgeExamples, options: { title: "Badge" } },
75
218
  { path: "button", type: 'screen', component: ButtonExamples, options: { title: "Button" } },
@@ -1,4 +1,4 @@
1
- import { NavigatorParam, RouteParam, ScreenOptions } from './types'
1
+ import { NavigatorParam, RouteParam, ScreenOptions, NotFoundComponentProps } from './types'
2
2
 
3
3
  import { TypedNavigator } from "@react-navigation/native";
4
4
  import { createNativeStackNavigator } from "@react-navigation/native-stack";
@@ -75,6 +75,24 @@ const createThemeAwareComponent = (OriginalComponent: React.ComponentType<any>)
75
75
  return Wrapped;
76
76
  };
77
77
 
78
+ /**
79
+ * Internal screen name for 404 pages
80
+ */
81
+ export const NOT_FOUND_SCREEN_NAME = '__notFound__';
82
+
83
+ /**
84
+ * Creates a NotFound screen component that receives path and params from route params
85
+ */
86
+ const createNotFoundScreen = (NotFoundComponent: React.ComponentType<NotFoundComponentProps>) => {
87
+ return React.memo((props: any) => {
88
+ const { route } = props;
89
+ const path = route?.params?.path ?? '';
90
+ const params = route?.params?.params;
91
+
92
+ return <NotFoundComponent path={path} params={params} />;
93
+ });
94
+ };
95
+
78
96
  /**
79
97
  * Build the Mobile navigator using React Navigation
80
98
  * @param params
@@ -104,6 +122,26 @@ export const buildNavigator = (params: NavigatorParam, parentPath = '') => {
104
122
  }
105
123
  : params.options;
106
124
 
125
+ // Build screens including optional 404 screen
126
+ const buildScreens = () => {
127
+ const screens = params.routes.map((child, index) => buildScreen(child, NavigatorType, parentPath, index));
128
+
129
+ // Add 404 screen if notFoundComponent is configured
130
+ if (params.notFoundComponent) {
131
+ const NotFoundScreen = createNotFoundScreen(params.notFoundComponent);
132
+ screens.push(
133
+ <NavigatorType.Screen
134
+ key={NOT_FOUND_SCREEN_NAME}
135
+ name={NOT_FOUND_SCREEN_NAME}
136
+ component={NotFoundScreen}
137
+ options={{ headerShown: false }}
138
+ />
139
+ );
140
+ }
141
+
142
+ return screens;
143
+ };
144
+
107
145
  // Special handling for drawer navigator with custom sidebar
108
146
  if (params.layout === 'drawer' && params.sidebarComponent) {
109
147
  return () => (
@@ -117,14 +155,14 @@ export const buildNavigator = (params: NavigatorParam, parentPath = '') => {
117
155
  />
118
156
  )}
119
157
  >
120
- {params.routes.map((child, index) => buildScreen(child, NavigatorType, parentPath, index))}
158
+ {buildScreens()}
121
159
  </NavigatorType.Navigator>
122
160
  );
123
161
  }
124
162
 
125
163
  return () => (
126
164
  <NavigatorType.Navigator screenOptions={screenOptions}>
127
- {params.routes.map((child, index) => buildScreen(child, NavigatorType, parentPath, index))}
165
+ {buildScreens()}
128
166
  </NavigatorType.Navigator>
129
167
  )
130
168
  }
@@ -1,8 +1,93 @@
1
- import React from 'react'
2
- import { Routes, Route } from '../router'
1
+ import React, { useEffect, useState, useRef } from 'react'
2
+ import { Routes, Route, useLocation, useParams } from '../router'
3
3
  import { DefaultStackLayout } from '../layouts/DefaultStackLayout'
4
4
  import { DefaultTabLayout } from '../layouts/DefaultTabLayout'
5
- import { NavigatorParam, RouteParam } from './types'
5
+ import { NavigatorParam, RouteParam, NotFoundComponentProps } from './types'
6
+ import { NavigateParams } from '../context/types'
7
+
8
+ /**
9
+ * Wrapper component for catch-all routes that:
10
+ * 1. Checks onInvalidRoute handler first
11
+ * 2. If handler returns NavigateParams, redirects (using absolute path)
12
+ * 3. If handler returns undefined or no handler, renders notFoundComponent (if provided)
13
+ */
14
+ const NotFoundWrapper = ({
15
+ component: Component,
16
+ onInvalidRoute
17
+ }: {
18
+ component?: React.ComponentType<NotFoundComponentProps>
19
+ onInvalidRoute?: (path: string) => NavigateParams | undefined
20
+ }) => {
21
+ const location = useLocation()
22
+ const params = useParams()
23
+ const [shouldRender, setShouldRender] = useState(false)
24
+ const redirectingRef = useRef(false)
25
+ const lastPathRef = useRef(location.pathname)
26
+
27
+ useEffect(() => {
28
+ // Reset state if path changed (navigated to a different invalid route)
29
+ if (lastPathRef.current !== location.pathname) {
30
+ lastPathRef.current = location.pathname
31
+ redirectingRef.current = false
32
+ setShouldRender(false)
33
+ }
34
+
35
+ // Prevent multiple redirects
36
+ if (redirectingRef.current) {
37
+ return
38
+ }
39
+
40
+ // Check if handler wants to redirect
41
+ if (onInvalidRoute) {
42
+ const redirectParams = onInvalidRoute(location.pathname)
43
+ if (redirectParams) {
44
+ // Mark as redirecting to prevent loops
45
+ redirectingRef.current = true
46
+
47
+ // Handler returned NavigateParams - redirect using absolute path
48
+ // Ensure path starts with / for absolute navigation
49
+ let targetPath = redirectParams.path
50
+ if (!targetPath.startsWith('/')) {
51
+ targetPath = `/${targetPath}`
52
+ }
53
+
54
+ // Substitute any vars in the path
55
+ if (redirectParams.vars) {
56
+ Object.entries(redirectParams.vars).forEach(([key, value]) => {
57
+ targetPath = targetPath.replace(`:${key}`, value)
58
+ })
59
+ }
60
+
61
+ // Use window.history for truly absolute navigation
62
+ // React Router's navigate can have issues in catch-all contexts
63
+ const replaceMode = redirectParams.replace ?? true
64
+ if (replaceMode) {
65
+ window.history.replaceState(null, '', targetPath)
66
+ } else {
67
+ window.history.pushState(null, '', targetPath)
68
+ }
69
+ // Trigger React Router to sync with the new URL
70
+ window.dispatchEvent(new PopStateEvent('popstate'))
71
+ return
72
+ }
73
+ }
74
+ // No redirect - show the 404 component (if provided)
75
+ setShouldRender(true)
76
+ }, [location.pathname, onInvalidRoute])
77
+
78
+ // Don't render until we've checked the handler
79
+ if (!shouldRender) {
80
+ return null
81
+ }
82
+
83
+ // If no component provided, render nothing (handler-only mode)
84
+ if (!Component) {
85
+ console.warn(`[Navigation] Invalid route "${location.pathname}" - no notFoundComponent configured and onInvalidRoute returned undefined`)
86
+ return null
87
+ }
88
+
89
+ return <Component path={location.pathname} params={params as Record<string, string>} />
90
+ }
6
91
 
7
92
  /**
8
93
  * Build the Web navigator using React Router v7 nested routes
@@ -46,18 +131,37 @@ const buildRoute = (params: RouteParam, index: number, isNested = false) => {
46
131
  );
47
132
  } else if (params.type === 'navigator') {
48
133
  // Get the layout component directly
49
- const LayoutComponent = params.layoutComponent ||
134
+ const LayoutComponent = params.layoutComponent ||
50
135
  (params.layout === 'tab' ? DefaultTabLayout : DefaultStackLayout);
51
-
136
+
52
137
  // Transform routes to include full paths for layout component
53
138
  const routesWithFullPaths = params.routes.map(route => ({
54
139
  ...route,
55
140
  fullPath: route.path
56
141
  }));
57
-
142
+
143
+ // Build child routes including catch-all for 404
144
+ const childRoutes = params.routes.map((child, childIndex) => buildRoute(child, childIndex, true));
145
+
146
+ // Add catch-all route if notFoundComponent or onInvalidRoute is configured
147
+ if (params.notFoundComponent || params.onInvalidRoute) {
148
+ childRoutes.push(
149
+ <Route
150
+ key="__notFound__"
151
+ path="*"
152
+ element={
153
+ <NotFoundWrapper
154
+ component={params.notFoundComponent}
155
+ onInvalidRoute={params.onInvalidRoute}
156
+ />
157
+ }
158
+ />
159
+ );
160
+ }
161
+
58
162
  return (
59
- <Route
60
- key={`${params.path}-${index}`}
163
+ <Route
164
+ key={`${params.path}-${index}`}
61
165
  path={routePath}
62
166
  element={
63
167
  <LayoutComponent
@@ -67,7 +171,7 @@ const buildRoute = (params: RouteParam, index: number, isNested = false) => {
67
171
  />
68
172
  }
69
173
  >
70
- {params.routes.map((child, childIndex) => buildRoute(child, childIndex, true))}
174
+ {childRoutes}
71
175
  </Route>
72
176
  );
73
177
  }
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import type { NavigateParams } from "../context/types";
2
3
 
3
4
  /**
4
5
  * Tab bar specific screen options
@@ -64,10 +65,37 @@ export type ScreenOptions = {
64
65
  } & NavigatorOptions;
65
66
 
66
67
 
68
+ /**
69
+ * Props passed to the notFoundComponent when an invalid route is accessed
70
+ */
71
+ export type NotFoundComponentProps = {
72
+ /** The full path that was attempted */
73
+ path: string
74
+ /** Any route parameters that were parsed from the path */
75
+ params?: Record<string, string>
76
+ }
77
+
67
78
  export type BaseNavigatorParam = {
68
79
  path: string
69
80
  type: 'navigator'
70
81
  options?: NavigatorOptions
82
+ /**
83
+ * Handler called when an invalid route is accessed.
84
+ * - Return NavigateParams to redirect to a different route
85
+ * - Return undefined to show the notFoundComponent (if set)
86
+ * If not defined, bubbles up to parent navigator.
87
+ *
88
+ * @param invalidPath - The path that was attempted but not found
89
+ * @returns NavigateParams to redirect, or undefined to use notFoundComponent
90
+ */
91
+ onInvalidRoute?: (invalidPath: string) => NavigateParams | undefined
92
+ /**
93
+ * Component to render/navigate to when route is invalid and onInvalidRoute returns undefined.
94
+ * - Web: Renders at the current URL via catch-all route
95
+ * - Native: Navigated to as a screen
96
+ * - Optional: If not set and nothing handles the route, a warning is logged
97
+ */
98
+ notFoundComponent?: React.ComponentType<NotFoundComponentProps>
71
99
  }
72
100
 
73
101
  export type TabNavigatorParam = {