@bedrock-core/navigation 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -0
- package/package.json +48 -0
- package/src/context.ts +67 -0
- package/src/hooks.ts +55 -0
- package/src/index.ts +26 -0
- package/src/navigators/resolve-screen.ts +28 -0
- package/src/navigators/stack-navigator.ts +159 -0
- package/src/reducer.ts +228 -0
- package/src/types.ts +456 -0
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# @bedrock-core/navigation
|
|
2
|
+
|
|
3
|
+
Stack-based navigation system for @bedrock-core/ui.
|
|
4
|
+
|
|
5
|
+
## ⚠️ MVP Status
|
|
6
|
+
|
|
7
|
+
This is a stack-only navigator. It does **not** support:
|
|
8
|
+
- Tabs or other navigator types
|
|
9
|
+
- Nested navigators
|
|
10
|
+
- Deep linking
|
|
11
|
+
- State persistence
|
|
12
|
+
- Transition animations
|
|
13
|
+
- Keep-alive inactive routes
|
|
14
|
+
|
|
15
|
+
## Core API
|
|
16
|
+
|
|
17
|
+
- **`NavigationContainer`** – Context provider that initializes and manages navigation state per player session. Wrap the entire navigator tree in this component as the root passed to `render()`.
|
|
18
|
+
- **`createStackNavigator(config)`** – Factory that returns a `{ Navigator }` object. Register screens by passing a `{ screens: { [name]: component | { screen, initialParams } } }` config.
|
|
19
|
+
- **`useNavigation<TRoutes>()`** – Hook returning a `NavigationHelpers<TRoutes>` object with: `navigate(name, params?)`, `push(name, params?)`, `goBack()`, `canGoBack()`, `reset(state)`, `setParams(name, params)`, `getState()`.
|
|
20
|
+
- **`useRoute<TRoutes, K>()`** – Hook returning the current `RouteObject<TRoutes[K]>` with shape `{ key: string, name: K, params: TRoutes[K] }`. Must be called from within a screen component.
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
import { NavigationContainer, createStackNavigator } from '@bedrock-core/navigation';
|
|
26
|
+
import { useNavigation, useRoute } from '@bedrock-core/navigation';
|
|
27
|
+
|
|
28
|
+
// Define screens
|
|
29
|
+
function HomeScreen() {
|
|
30
|
+
const navigation = useNavigation();
|
|
31
|
+
return (
|
|
32
|
+
<Button onPress={() => navigation.navigate('Profile', { userId: 42 })}>
|
|
33
|
+
Go to Profile
|
|
34
|
+
</Button>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ProfileScreen() {
|
|
39
|
+
const route = useRoute();
|
|
40
|
+
const { userId } = route.params;
|
|
41
|
+
const navigation = useNavigation();
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
<Text>Profile: {userId}</Text>
|
|
45
|
+
<Button onPress={() => navigation.goBack()}>Back</Button>
|
|
46
|
+
</>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Create navigator
|
|
51
|
+
const Stack = createStackNavigator({
|
|
52
|
+
screens: {
|
|
53
|
+
Home: HomeScreen,
|
|
54
|
+
Profile: {
|
|
55
|
+
screen: ProfileScreen,
|
|
56
|
+
initialParams: { userId: 0 }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Render once per player
|
|
62
|
+
render(
|
|
63
|
+
<NavigationContainer>
|
|
64
|
+
<Stack.Navigator initialRouteName="Home" />
|
|
65
|
+
</NavigationContainer>,
|
|
66
|
+
player
|
|
67
|
+
);
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Key Architecture
|
|
71
|
+
|
|
72
|
+
1. **Single root render per player** – Navigation state changes do NOT trigger new `render()` calls.
|
|
73
|
+
2. **Action-driven updates** – All screen transitions happen through `navigate`, `push`, `goBack`, etc.
|
|
74
|
+
3. **Reuses present loop** – Button callbacks naturally trigger screen updates via the runtime's present cycle.
|
|
75
|
+
4. **No nested renders** – Screen components must never call `render()` directly.
|
|
76
|
+
|
|
77
|
+
See [comprehensive documentation](../../docs/) for more details.
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bedrock-core/navigation",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "@bedrock-core/ui navigation system",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ui",
|
|
7
|
+
"navigation",
|
|
8
|
+
"minecraft",
|
|
9
|
+
"bedrock",
|
|
10
|
+
"typescript",
|
|
11
|
+
"framework"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "DrAv0011",
|
|
15
|
+
"contributors": [
|
|
16
|
+
{
|
|
17
|
+
"name": "DrAv0011",
|
|
18
|
+
"email": "contact@drav.dev",
|
|
19
|
+
"url": "https://drav.dev"
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"repository": "github:bedrock-core/ui",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "src/index.ts",
|
|
25
|
+
"types": "src/index.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./src/index.ts",
|
|
29
|
+
"import": "./src/index.ts"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"src"
|
|
34
|
+
],
|
|
35
|
+
"sideEffects": false,
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.json",
|
|
38
|
+
"lint": "eslint ."
|
|
39
|
+
},
|
|
40
|
+
"packageManager": "yarn@4.9.3",
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@bedrock-core/ui-runtime": "^0.6.0",
|
|
43
|
+
"typescript": "^6.0.3"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@bedrock-core/ui-runtime": "*"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createContext, JSX } from '@bedrock-core/ui-runtime';
|
|
2
|
+
import type { NavigationHelpers, NavigationState } from './types';
|
|
3
|
+
import type { StackAction } from './reducer';
|
|
4
|
+
|
|
5
|
+
export interface NavigationContextValue<
|
|
6
|
+
TRoutes extends Record<string, unknown> = Record<string, unknown>,
|
|
7
|
+
> {
|
|
8
|
+
state: NavigationState<TRoutes>;
|
|
9
|
+
dispatch: (action: StackAction<TRoutes>) => void;
|
|
10
|
+
helpers: NavigationHelpers<TRoutes>;
|
|
11
|
+
routeNames: Extract<keyof TRoutes, string>[];
|
|
12
|
+
initialRouteName?: Extract<keyof TRoutes, string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Module-level context — unparameterized so it can be created once at module load.
|
|
17
|
+
* Types are recovered via casts inside the hooks and the navigators.
|
|
18
|
+
*/
|
|
19
|
+
export const NavigationContext = createContext<NavigationContextValue | undefined>(undefined);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Provides a navigation context boundary. Wrap your app root with this.
|
|
23
|
+
* The actual state is owned by the Navigator inside; this just establishes
|
|
24
|
+
* the context slot with a stale placeholder so hooks can detect missing providers.
|
|
25
|
+
*/
|
|
26
|
+
export function NavigationContainer({
|
|
27
|
+
children,
|
|
28
|
+
initialState,
|
|
29
|
+
}: {
|
|
30
|
+
children: JSX.Node;
|
|
31
|
+
initialState?: NavigationState;
|
|
32
|
+
}): JSX.Element {
|
|
33
|
+
const placeholder: NavigationContextValue = {
|
|
34
|
+
state: initialState ?? {
|
|
35
|
+
type: 'stack',
|
|
36
|
+
key: 'stack-placeholder',
|
|
37
|
+
routeNames: [],
|
|
38
|
+
routes: [],
|
|
39
|
+
index: 0,
|
|
40
|
+
stale: true,
|
|
41
|
+
},
|
|
42
|
+
dispatch: (): void => {},
|
|
43
|
+
helpers: buildNoopHelpers(),
|
|
44
|
+
routeNames: [],
|
|
45
|
+
initialRouteName: undefined,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
type: 'context-provider',
|
|
50
|
+
props: { __context: NavigationContext, value: placeholder, children },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** No-op helpers for the placeholder context before Navigator mounts. */
|
|
55
|
+
function buildNoopHelpers(): NavigationHelpers<Record<string, unknown>> {
|
|
56
|
+
return {
|
|
57
|
+
navigate: (): void => {},
|
|
58
|
+
push: (): void => {},
|
|
59
|
+
goBack: (): void => {},
|
|
60
|
+
canGoBack: (): boolean => false,
|
|
61
|
+
reset: (): void => {},
|
|
62
|
+
setParams: (): void => {},
|
|
63
|
+
getState: (): NavigationState<Record<string, unknown>> => ({
|
|
64
|
+
type: 'stack', key: '', routeNames: [], routes: [], index: 0, stale: true,
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useContext } from '@bedrock-core/ui-runtime';
|
|
2
|
+
import type { NavigationHelpers, RouteObject } from './types';
|
|
3
|
+
import { NavigationContext } from './context';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hook to access the navigation object within a screen.
|
|
7
|
+
* Provides methods like navigate, goBack, push, etc.
|
|
8
|
+
*
|
|
9
|
+
* Always pass TRoutes to get full type safety: useNavigation<MyRoutes>()
|
|
10
|
+
*/
|
|
11
|
+
export function useNavigation<TRoutes extends Record<string, unknown>>(): NavigationHelpers<TRoutes> {
|
|
12
|
+
const ctx = useContext(NavigationContext);
|
|
13
|
+
|
|
14
|
+
if (!ctx) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
'useNavigation must be called within a NavigationContainer. '
|
|
17
|
+
+ 'Make sure your component is rendered inside a NavigationContainer.',
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return ctx.helpers;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Hook to access the current route object within a screen.
|
|
26
|
+
* Provides route name and typed params.
|
|
27
|
+
*
|
|
28
|
+
* Usage: useRoute<MyRoutes, 'Profile'>()
|
|
29
|
+
*/
|
|
30
|
+
export function useRoute<
|
|
31
|
+
TRoutes extends Record<string, unknown>,
|
|
32
|
+
K extends keyof TRoutes = keyof TRoutes,
|
|
33
|
+
>(): RouteObject<TRoutes[K]> {
|
|
34
|
+
const ctx = useContext(NavigationContext);
|
|
35
|
+
|
|
36
|
+
if (!ctx) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
'useRoute must be called within a NavigationContainer. '
|
|
39
|
+
+ 'Make sure your component is rendered inside a NavigationContainer.',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const focusedRoute = ctx.state.routes[ctx.state.index];
|
|
44
|
+
|
|
45
|
+
if (!focusedRoute) {
|
|
46
|
+
throw new Error('No focused route found in navigation state');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
key: focusedRoute.key,
|
|
51
|
+
name: focusedRoute.name,
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Route.params is unknown internally; caller asserts TRoutes[K] at the hook call site
|
|
53
|
+
params: focusedRoute.params as RouteObject<TRoutes[K]>['params'],
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bedrock-core/navigation - Stack navigation system MVP
|
|
3
|
+
*
|
|
4
|
+
* A single-root-render navigation library for Minecraft Bedrock UI.
|
|
5
|
+
* Inspired by React Navigation but designed for the ui-runtime's presentation loop.
|
|
6
|
+
*
|
|
7
|
+
* Key principle: one render() call per player, all screen transitions via navigation actions.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { NavigationContainer } from './context';
|
|
11
|
+
export { createStackNavigator } from './navigators/stack-navigator';
|
|
12
|
+
export { useNavigation, useRoute } from './hooks';
|
|
13
|
+
export { stackReducer, type StackAction, type ScreenDefaults } from './reducer';
|
|
14
|
+
|
|
15
|
+
export type {
|
|
16
|
+
Route,
|
|
17
|
+
NavigationState,
|
|
18
|
+
StackNavigatorOptions,
|
|
19
|
+
NavigationHelpers,
|
|
20
|
+
RouteObject,
|
|
21
|
+
ScreenProps,
|
|
22
|
+
ScreenComponent,
|
|
23
|
+
ScreensMap,
|
|
24
|
+
RouteEntry,
|
|
25
|
+
ResetRouteEntry,
|
|
26
|
+
} from './types';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ScreenComponent, ScreensMap } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a screen component from a screens map entry.
|
|
5
|
+
* Each entry is either a bare component or an object `{ screen, initialParams? }`.
|
|
6
|
+
* Returns `undefined` when the route name has no entry.
|
|
7
|
+
*
|
|
8
|
+
* Shared by both the stack and tab navigators.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveScreenComponent<
|
|
11
|
+
TRoutes extends Record<string, unknown>,
|
|
12
|
+
K extends Extract<keyof TRoutes, string>,
|
|
13
|
+
>(
|
|
14
|
+
screens: ScreensMap<TRoutes>,
|
|
15
|
+
name: K,
|
|
16
|
+
): ScreenComponent<TRoutes, K> | undefined {
|
|
17
|
+
const entry = screens[name];
|
|
18
|
+
|
|
19
|
+
if (entry == null) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (typeof entry === 'function') {
|
|
24
|
+
return entry;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return entry.screen;
|
|
28
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion --
|
|
2
|
+
Type assertions are required at the navigation context boundary: Object.keys casts,
|
|
3
|
+
unparameterized context recovery, discriminated-union action payloads, and JSX element
|
|
4
|
+
construction. Type safety is enforced at the NavigationHelpers / NavigationContextValue
|
|
5
|
+
interfaces rather than at each individual assertion site. */
|
|
6
|
+
|
|
7
|
+
import { useContext, useState, JSX } from '@bedrock-core/ui-runtime';
|
|
8
|
+
import type {
|
|
9
|
+
NavigationHelpers,
|
|
10
|
+
NavigationState,
|
|
11
|
+
StackNavigatorOptions,
|
|
12
|
+
} from '../types';
|
|
13
|
+
import { NavigationContext } from '../context';
|
|
14
|
+
import type { NavigationContextValue } from '../context';
|
|
15
|
+
import { stackReducer, type StackAction, type ScreenDefaults } from '../reducer';
|
|
16
|
+
import { resolveScreenComponent } from './resolve-screen';
|
|
17
|
+
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -- return type is a complex generic object; consumers use the inferred type via `typeof Stack`
|
|
19
|
+
export function createStackNavigator<TRoutes extends Record<string, unknown>>(
|
|
20
|
+
options: StackNavigatorOptions<TRoutes>,
|
|
21
|
+
) {
|
|
22
|
+
const routeNames = Object.keys(options.screens) as Extract<keyof TRoutes, string>[];
|
|
23
|
+
const { initialRouteName } = options;
|
|
24
|
+
|
|
25
|
+
// Extract initialParams from each screen entry into a screenDefaults map.
|
|
26
|
+
const screenDefaults: ScreenDefaults<TRoutes> = {};
|
|
27
|
+
|
|
28
|
+
for (const key of routeNames) {
|
|
29
|
+
const entry = options.screens[key];
|
|
30
|
+
|
|
31
|
+
//
|
|
32
|
+
if (entry != null && typeof entry === 'object' && 'initialParams' in entry) {
|
|
33
|
+
(screenDefaults as Record<string, unknown>)[key] = (entry as { initialParams?: unknown }).initialParams;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const reducerConfig = { routeNames, initialRouteName, screenDefaults };
|
|
38
|
+
|
|
39
|
+
function Navigator({
|
|
40
|
+
initialRouteName: propInitialRouteName,
|
|
41
|
+
}: {
|
|
42
|
+
initialRouteName?: Extract<keyof TRoutes, string>;
|
|
43
|
+
}): JSX.Element {
|
|
44
|
+
const effectiveInitialRoute
|
|
45
|
+
= propInitialRouteName ?? initialRouteName ?? routeNames[0];
|
|
46
|
+
|
|
47
|
+
const cfg = { ...reducerConfig, initialRouteName: effectiveInitialRoute };
|
|
48
|
+
|
|
49
|
+
const existingCtx = useContext(NavigationContext) as NavigationContextValue<TRoutes> | undefined;
|
|
50
|
+
|
|
51
|
+
const [navState, setNavState] = useState<NavigationState<TRoutes>>(() =>
|
|
52
|
+
existingCtx != null && !existingCtx.state.stale
|
|
53
|
+
? existingCtx.state
|
|
54
|
+
: stackReducer<TRoutes>(undefined, { type: 'GO_BACK' }, cfg),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const dispatch = (action: StackAction<TRoutes>): void => {
|
|
58
|
+
setNavState(prev => stackReducer<TRoutes>(prev, action, cfg));
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const helpers = buildHelpers<TRoutes>(navState, dispatch);
|
|
62
|
+
|
|
63
|
+
// ── Resolve active screen ─────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
const focusedRoute = navState.routes[navState.index];
|
|
66
|
+
|
|
67
|
+
const activeRouteName = (focusedRoute?.name ?? effectiveInitialRoute) as Extract<keyof TRoutes, string>;
|
|
68
|
+
|
|
69
|
+
const ActiveScreen = resolveScreenComponent(options.screens, activeRouteName);
|
|
70
|
+
|
|
71
|
+
if (ActiveScreen == null) {
|
|
72
|
+
return { type: 'fragment', props: { children: [] } };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const routeObject = {
|
|
76
|
+
key: focusedRoute?.key ?? activeRouteName,
|
|
77
|
+
name: activeRouteName,
|
|
78
|
+
// Stored params win over defaults; screen always sees at least its initialParams.
|
|
79
|
+
params: mergeRouteParams(
|
|
80
|
+
screenDefaults[activeRouteName],
|
|
81
|
+
focusedRoute?.params,
|
|
82
|
+
),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const contextValue: NavigationContextValue<TRoutes> = {
|
|
86
|
+
state: navState,
|
|
87
|
+
dispatch,
|
|
88
|
+
helpers,
|
|
89
|
+
routeNames,
|
|
90
|
+
initialRouteName: effectiveInitialRoute,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
type: 'context-provider',
|
|
95
|
+
props: {
|
|
96
|
+
__context: NavigationContext,
|
|
97
|
+
value: contextValue,
|
|
98
|
+
children: {
|
|
99
|
+
type: ActiveScreen as (props: Record<string, unknown>) => JSX.Element,
|
|
100
|
+
props: { navigation: helpers, route: routeObject },
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
Navigator,
|
|
108
|
+
routeNames,
|
|
109
|
+
initialRouteName: initialRouteName ?? routeNames[0],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function buildHelpers<TRoutes extends Record<string, unknown>>(
|
|
114
|
+
navState: NavigationState<TRoutes>,
|
|
115
|
+
dispatch: (action: StackAction<TRoutes>) => void,
|
|
116
|
+
): NavigationHelpers<TRoutes> {
|
|
117
|
+
return {
|
|
118
|
+
navigate(...args): void {
|
|
119
|
+
const [name, params] = args as [Extract<keyof TRoutes, string>, unknown];
|
|
120
|
+
|
|
121
|
+
dispatch({ type: 'NAVIGATE', payload: { name, params } } as unknown as StackAction<TRoutes>);
|
|
122
|
+
},
|
|
123
|
+
push(...args): void {
|
|
124
|
+
const [name, params] = args as [Extract<keyof TRoutes, string>, unknown];
|
|
125
|
+
|
|
126
|
+
dispatch({ type: 'PUSH', payload: { name, params } } as unknown as StackAction<TRoutes>);
|
|
127
|
+
},
|
|
128
|
+
goBack(): void {
|
|
129
|
+
dispatch({ type: 'GO_BACK' });
|
|
130
|
+
},
|
|
131
|
+
canGoBack(): boolean {
|
|
132
|
+
return navState.index > 0;
|
|
133
|
+
},
|
|
134
|
+
reset(resetState): void {
|
|
135
|
+
dispatch({ type: 'RESET', payload: resetState } as unknown as StackAction<TRoutes>);
|
|
136
|
+
},
|
|
137
|
+
setParams(name, params): void {
|
|
138
|
+
dispatch({ type: 'SET_PARAMS', payload: { name, params } } as unknown as StackAction<TRoutes>);
|
|
139
|
+
},
|
|
140
|
+
getState(): NavigationState<TRoutes> {
|
|
141
|
+
return navState;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function mergeRouteParams(
|
|
147
|
+
defaults: Record<string, unknown> | undefined,
|
|
148
|
+
stored: unknown,
|
|
149
|
+
): unknown {
|
|
150
|
+
if (defaults == null) {
|
|
151
|
+
return stored;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (stored == null) {
|
|
155
|
+
return defaults;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { ...defaults, ...(stored as Record<string, unknown>) };
|
|
159
|
+
}
|
package/src/reducer.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure stack navigation state reducer.
|
|
3
|
+
* Handles all navigation actions without side effects.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { NavigationState, Route, RouteEntry, ResetRouteEntry } from './types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Per-screen default params applied when a route is first created and no params
|
|
10
|
+
* are supplied by the action. Action params always win on top of these defaults.
|
|
11
|
+
*/
|
|
12
|
+
export type ScreenDefaults<TRoutes extends Record<string, unknown>> = {
|
|
13
|
+
[K in Extract<keyof TRoutes, string>]?: Partial<
|
|
14
|
+
Exclude<TRoutes[K], undefined> & Record<string, unknown>
|
|
15
|
+
>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* SET_PARAMS payload — only valid for routes that have params (not `undefined` routes).
|
|
20
|
+
*/
|
|
21
|
+
type SetParamsEntry<TRoutes extends Record<string, unknown>> = {
|
|
22
|
+
[K in Extract<keyof TRoutes, string>]: [TRoutes[K]] extends [undefined]
|
|
23
|
+
? never
|
|
24
|
+
: { name: K; params: Partial<Exclude<TRoutes[K], undefined> & Record<string, unknown>> };
|
|
25
|
+
}[Extract<keyof TRoutes, string>];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Navigation actions, fully typed from the routes map.
|
|
29
|
+
*/
|
|
30
|
+
export type StackAction<TRoutes extends Record<string, unknown> = Record<string, unknown>>
|
|
31
|
+
= | { type: 'NAVIGATE'; payload: RouteEntry<TRoutes> }
|
|
32
|
+
| { type: 'PUSH'; payload: RouteEntry<TRoutes> }
|
|
33
|
+
| { type: 'GO_BACK' }
|
|
34
|
+
| { type: 'RESET'; payload: { routes: ResetRouteEntry<TRoutes>[]; index: number } }
|
|
35
|
+
| { type: 'SET_PARAMS'; payload: SetParamsEntry<TRoutes> };
|
|
36
|
+
|
|
37
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function generateRouteKey(name: string): string {
|
|
40
|
+
return `${name}-${Math.random().toString(36).slice(2, 11)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Merge action params on top of screen defaults. Both may be absent.
|
|
45
|
+
* `incoming` is typed as unknown because Route.params is loosely typed internally;
|
|
46
|
+
* type safety is enforced at the NavigationHelpers dispatch boundary.
|
|
47
|
+
*/
|
|
48
|
+
function mergeParams(
|
|
49
|
+
defaults: Record<string, unknown> | undefined,
|
|
50
|
+
incoming: unknown,
|
|
51
|
+
): unknown {
|
|
52
|
+
if (defaults == null && incoming == null) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- params are always plain objects or undefined at runtime; Route.params is typed as unknown for internal flexibility
|
|
57
|
+
return { ...defaults, ...(incoming as Record<string, unknown> | undefined) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Reducer ──────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Reduce the navigation state based on an action.
|
|
64
|
+
* Pure function with no side effects.
|
|
65
|
+
*/
|
|
66
|
+
export function stackReducer<TRoutes extends Record<string, unknown>>(
|
|
67
|
+
state: NavigationState<TRoutes> | undefined,
|
|
68
|
+
action: StackAction<TRoutes>,
|
|
69
|
+
config: {
|
|
70
|
+
routeNames: Extract<keyof TRoutes, string>[];
|
|
71
|
+
initialRouteName?: Extract<keyof TRoutes, string>;
|
|
72
|
+
screenDefaults?: ScreenDefaults<TRoutes>;
|
|
73
|
+
},
|
|
74
|
+
): NavigationState<TRoutes> {
|
|
75
|
+
const { routeNames, initialRouteName, screenDefaults } = config;
|
|
76
|
+
|
|
77
|
+
// ── Initialization ──────────────────────────────────────────────────────────
|
|
78
|
+
if (state == null || state.stale) {
|
|
79
|
+
const firstName = initialRouteName ?? routeNames[0];
|
|
80
|
+
|
|
81
|
+
if (firstName == null) {
|
|
82
|
+
throw new Error('stackReducer: routeNames is empty — at least one screen is required.');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const defaults = screenDefaults?.[firstName] as Record<string, unknown> | undefined;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
type: 'stack',
|
|
89
|
+
key: `stack-${Math.random().toString(36).slice(2, 11)}`,
|
|
90
|
+
routeNames,
|
|
91
|
+
routes: [{ key: generateRouteKey(firstName), name: firstName, params: defaults }],
|
|
92
|
+
index: 0,
|
|
93
|
+
stale: false,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
switch (action.type) {
|
|
98
|
+
// ── NAVIGATE ──────────────────────────────────────────────────────────────
|
|
99
|
+
// Navigate to a route. If it already exists in the stack, pop back to it
|
|
100
|
+
// (discard everything after it). If it is new, push it onto the stack.
|
|
101
|
+
case 'NAVIGATE': {
|
|
102
|
+
const { name, params } = action.payload;
|
|
103
|
+
|
|
104
|
+
if (!routeNames.includes(name)) {
|
|
105
|
+
return state;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const defaults = screenDefaults?.[name] as Record<string, unknown> | undefined;
|
|
109
|
+
const mergedParams = mergeParams(defaults, params);
|
|
110
|
+
|
|
111
|
+
const existingIndex = state.routes.findIndex(r => r.name === name);
|
|
112
|
+
|
|
113
|
+
if (existingIndex !== -1) {
|
|
114
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Route.params is typed as unknown internally; safe to spread as Record at runtime
|
|
115
|
+
const existingParams = state.routes[existingIndex].params as Record<string, unknown> | undefined;
|
|
116
|
+
const updatedRoute: Route = {
|
|
117
|
+
...state.routes[existingIndex],
|
|
118
|
+
params: mergeParams(existingParams, mergedParams),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
...state,
|
|
123
|
+
// Slice to existingIndex + 1 to discard all forward history (pop-to behavior).
|
|
124
|
+
routes: [...state.routes.slice(0, existingIndex), updatedRoute],
|
|
125
|
+
index: existingIndex,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
...state,
|
|
131
|
+
routes: [...state.routes, { key: generateRouteKey(name), name, params: mergedParams }],
|
|
132
|
+
index: state.routes.length,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── PUSH ─────────────────────────────────────────────────────────────────
|
|
137
|
+
// Always push a new entry, even if the route is already in the stack.
|
|
138
|
+
case 'PUSH': {
|
|
139
|
+
const { name, params } = action.payload;
|
|
140
|
+
|
|
141
|
+
if (!routeNames.includes(name)) {
|
|
142
|
+
return state;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const defaults = screenDefaults?.[name] as Record<string, unknown> | undefined;
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
...state,
|
|
149
|
+
routes: [
|
|
150
|
+
...state.routes,
|
|
151
|
+
{ key: generateRouteKey(name), name, params: mergeParams(defaults, params) },
|
|
152
|
+
],
|
|
153
|
+
index: state.routes.length,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── GO_BACK ───────────────────────────────────────────────────────────────
|
|
158
|
+
case 'GO_BACK': {
|
|
159
|
+
if (state.index <= 0) {
|
|
160
|
+
return state;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
...state,
|
|
165
|
+
routes: state.routes.slice(0, state.index),
|
|
166
|
+
index: state.index - 1,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── RESET ─────────────────────────────────────────────────────────────────
|
|
171
|
+
case 'RESET': {
|
|
172
|
+
const { routes: incoming, index: requestedIndex } = action.payload;
|
|
173
|
+
|
|
174
|
+
const built = incoming
|
|
175
|
+
.filter(r => routeNames.includes(r.name))
|
|
176
|
+
.map((r) => {
|
|
177
|
+
const defaults = screenDefaults?.[r.name] as Record<string, unknown> | undefined;
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
key: generateRouteKey(r.name),
|
|
181
|
+
name: r.name,
|
|
182
|
+
params: mergeParams(defaults, r.params),
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
if (built.length === 0) {
|
|
187
|
+
return state;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
...state,
|
|
192
|
+
routes: built,
|
|
193
|
+
index: Math.max(0, Math.min(requestedIndex, built.length - 1)),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── SET_PARAMS ────────────────────────────────────────────────────────────
|
|
198
|
+
case 'SET_PARAMS': {
|
|
199
|
+
const { name, params: incoming } = action.payload;
|
|
200
|
+
|
|
201
|
+
const targetIndex = state.routes.findIndex(r => r.name === name);
|
|
202
|
+
|
|
203
|
+
if (targetIndex === -1) {
|
|
204
|
+
return state;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Route.params is typed as unknown internally; safe to spread as Record at runtime
|
|
208
|
+
const existing = state.routes[targetIndex].params as Record<string, unknown> | undefined;
|
|
209
|
+
const updatedRoute: Route = {
|
|
210
|
+
...state.routes[targetIndex],
|
|
211
|
+
|
|
212
|
+
params: { ...existing, ...(incoming as Record<string, unknown>) },
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
...state,
|
|
217
|
+
routes: [
|
|
218
|
+
...state.routes.slice(0, targetIndex),
|
|
219
|
+
updatedRoute,
|
|
220
|
+
...state.routes.slice(targetIndex + 1),
|
|
221
|
+
],
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
default:
|
|
226
|
+
return state;
|
|
227
|
+
}
|
|
228
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import type { JSX } from '@bedrock-core/ui-runtime';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rest-argument tuple for `navigate` / `push` calls.
|
|
5
|
+
*
|
|
6
|
+
* Driven by the **route-level** param type — four cases:
|
|
7
|
+
* - `undefined` → params argument forbidden: `[name]`
|
|
8
|
+
* - `T | undefined` → params argument optional: `[name]` or `[name, params]`
|
|
9
|
+
* - `T` → params argument required: `[name, params]`
|
|
10
|
+
* - `{ req: A; opt?: B }` → params argument required, but individual properties
|
|
11
|
+
* inside the object can still be optional as normal
|
|
12
|
+
*
|
|
13
|
+
* Uses non-distributive `[T] extends [undefined]` so a union like `T | undefined`
|
|
14
|
+
* falls through to the optional branch instead of splitting across both branches.
|
|
15
|
+
* `Exclude<TParams, undefined>` strips only the route-level `undefined` from the union —
|
|
16
|
+
* it never touches optional properties inside the params object.
|
|
17
|
+
*/
|
|
18
|
+
type NavigateArgs<K extends string, TParams> = [TParams] extends [undefined]
|
|
19
|
+
? [name: K]
|
|
20
|
+
: [undefined] extends [TParams]
|
|
21
|
+
? [name: K] | [name: K, params: Exclude<TParams, undefined>]
|
|
22
|
+
: [name: K, params: TParams];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Navigation API injected into every screen and exposed via `useNavigation`.
|
|
26
|
+
*
|
|
27
|
+
* The generic `TRoutes` parameter constrains every call so route names and
|
|
28
|
+
* their param shapes are validated at compile time.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* type AppRoutes = {
|
|
33
|
+
* Home: undefined;
|
|
34
|
+
* Profile: { userId: string };
|
|
35
|
+
* Settings: { tab?: string };
|
|
36
|
+
* };
|
|
37
|
+
*
|
|
38
|
+
* function MyComponent({ navigation }: ScreenProps<AppRoutes, 'Home'>) {
|
|
39
|
+
* // undefined route — name only, params forbidden:
|
|
40
|
+
* navigation.navigate('Home');
|
|
41
|
+
*
|
|
42
|
+
* // Required params — TypeScript errors if userId is missing:
|
|
43
|
+
* navigation.navigate('Profile', { userId: '42' });
|
|
44
|
+
*
|
|
45
|
+
* // Optional params — both forms are valid:
|
|
46
|
+
* navigation.navigate('Feed');
|
|
47
|
+
* navigation.navigate('Feed', { sort: 'top' });
|
|
48
|
+
*
|
|
49
|
+
* // Push always adds a new stack entry:
|
|
50
|
+
* navigation.push('Feed', { sort: 'latest' });
|
|
51
|
+
*
|
|
52
|
+
* // Go back one screen:
|
|
53
|
+
* navigation.goBack();
|
|
54
|
+
*
|
|
55
|
+
* // Guard against going back when there is nothing to go back to:
|
|
56
|
+
* if (navigation.canGoBack()) navigation.goBack();
|
|
57
|
+
*
|
|
58
|
+
* // Replace the entire stack (e.g. after logout):
|
|
59
|
+
* navigation.reset({ routes: [{ name: 'Home' }], index: 0 });
|
|
60
|
+
*
|
|
61
|
+
* // Merge new values into an already-mounted screen's params:
|
|
62
|
+
* navigation.setParams('Settings', { tab: 'account' });
|
|
63
|
+
*
|
|
64
|
+
* // Read the current stack state:
|
|
65
|
+
* const state = navigation.getState();
|
|
66
|
+
* }
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export interface NavigationHelpers<TRoutes extends Record<string, unknown>> {
|
|
70
|
+
/**
|
|
71
|
+
* Navigate to a route by name.
|
|
72
|
+
* If the route is already in the stack, navigates back to it instead of
|
|
73
|
+
* pushing a new copy.
|
|
74
|
+
*
|
|
75
|
+
* Param rules mirror the route definition:
|
|
76
|
+
* - `undefined` route → only the name, no params argument
|
|
77
|
+
* - `T` route → name + required params object
|
|
78
|
+
* - `T | undefined` → name alone, or name + optional params object
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```ts
|
|
82
|
+
* navigation.navigate('Home'); // undefined route
|
|
83
|
+
* navigation.navigate('Profile', { userId: '42' }); // required params
|
|
84
|
+
* navigation.navigate('User', { userId: 1 }); // mixed — req only
|
|
85
|
+
* navigation.navigate('User', { userId: 1, username: 'Alice' }); // mixed — req + opt
|
|
86
|
+
* navigation.navigate('Feed'); // optional — no params
|
|
87
|
+
* navigation.navigate('Feed', { sort: 'latest' }); // optional — with params
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
navigate<K extends Extract<keyof TRoutes, string>>(
|
|
91
|
+
...args: NavigateArgs<K, TRoutes[K]>
|
|
92
|
+
): void;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Always push a new entry onto the stack, even if that route is already present.
|
|
96
|
+
* Useful when you want multiple instances of the same screen (e.g. nested profiles).
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```ts
|
|
100
|
+
* navigation.push('Profile', { userId: '99' });
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
push<K extends Extract<keyof TRoutes, string>>(
|
|
104
|
+
...args: NavigateArgs<K, TRoutes[K]>
|
|
105
|
+
): void;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Pop the current screen and return to the previous one.
|
|
109
|
+
* Has no effect when the stack only has one entry — check `canGoBack` first.
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```ts
|
|
113
|
+
* navigation.goBack();
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
goBack(): void;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Returns `true` when there is at least one screen behind the current one.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* if (navigation.canGoBack()) {
|
|
124
|
+
* navigation.goBack();
|
|
125
|
+
* }
|
|
126
|
+
* ```
|
|
127
|
+
*/
|
|
128
|
+
canGoBack(): boolean;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Replace the entire navigation stack with a new one.
|
|
132
|
+
* `index` must point to the focused route in the `routes` array.
|
|
133
|
+
* Params inside `routes` are partial — omit any you want to keep at defaults.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* ```ts
|
|
137
|
+
* // After logout, reset to the Home screen:
|
|
138
|
+
* navigation.reset({ routes: [{ name: 'Home' }], index: 0 });
|
|
139
|
+
*
|
|
140
|
+
* // Restore a two-screen stack with Profile focused:
|
|
141
|
+
* navigation.reset({
|
|
142
|
+
* routes: [{ name: 'Home' }, { name: 'Profile', params: { userId: '42' } }],
|
|
143
|
+
* index: 1,
|
|
144
|
+
* });
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
reset(state: { routes: ResetRouteEntry<TRoutes>[]; index: number }): void;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Merge partial params into a screen that is already in the stack.
|
|
151
|
+
* Only valid for routes that have a params type — TypeScript will error for
|
|
152
|
+
* parameterless routes (`TRoutes[K] extends undefined`).
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* ```ts
|
|
156
|
+
* // Switch the active tab on the Settings screen without re-mounting it:
|
|
157
|
+
* navigation.setParams('Settings', { tab: 'privacy' });
|
|
158
|
+
* ```
|
|
159
|
+
*/
|
|
160
|
+
setParams<K extends Extract<keyof TRoutes, string>>(
|
|
161
|
+
name: K,
|
|
162
|
+
params: [TRoutes[K]] extends [undefined] ? never : Partial<Exclude<TRoutes[K], undefined> & Record<string, unknown>>,
|
|
163
|
+
): void;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Returns a snapshot of the current navigation state (stack, index, etc.).
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* ```ts
|
|
170
|
+
* const { routes, index } = navigation.getState();
|
|
171
|
+
* const currentRoute = routes[index];
|
|
172
|
+
* ```
|
|
173
|
+
*/
|
|
174
|
+
getState(): NavigationState<TRoutes>;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Discriminated union of every valid `{ name, params }` pair for `TRoutes`.
|
|
179
|
+
* Used internally for NAVIGATE and PUSH action payloads to guarantee that the
|
|
180
|
+
* correct params type is paired with each route name.
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* ```ts
|
|
184
|
+
* type AppRoutes = {
|
|
185
|
+
* Home: undefined;
|
|
186
|
+
* Profile: { userId: string };
|
|
187
|
+
* User: { userId: number; username?: string };
|
|
188
|
+
* Feed: { sort: 'latest' | 'top' } | undefined;
|
|
189
|
+
* };
|
|
190
|
+
*
|
|
191
|
+
* // Resolves to:
|
|
192
|
+
* // | { name: 'Home'; params?: undefined }
|
|
193
|
+
* // | { name: 'Profile'; params: { userId: string } }
|
|
194
|
+
* // | { name: 'User'; params: { userId: number; username?: string } }
|
|
195
|
+
* // | { name: 'Feed'; params?: { sort: 'latest' | 'top' } }
|
|
196
|
+
* type Entry = RouteEntry<AppRoutes>;
|
|
197
|
+
*
|
|
198
|
+
* const a: Entry = { name: 'Home' };
|
|
199
|
+
* const b: Entry = { name: 'Profile', params: { userId: '1' } };
|
|
200
|
+
* const c: Entry = { name: 'Profile' }; // TS error — missing params
|
|
201
|
+
* const d: Entry = { name: 'User', params: { userId: 1 } }; // ok — username optional
|
|
202
|
+
* const e: Entry = { name: 'User', params: { userId: 1, username: 'Alice' } }; // also ok
|
|
203
|
+
* const f: Entry = { name: 'Feed' }; // ok — params optional
|
|
204
|
+
* const g: Entry = { name: 'Feed', params: { sort: 'top' } }; // also ok
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
207
|
+
export type RouteEntry<TRoutes extends Record<string, unknown>> = {
|
|
208
|
+
[K in Extract<keyof TRoutes, string>]: [TRoutes[K]] extends [undefined]
|
|
209
|
+
? { name: K; params?: undefined }
|
|
210
|
+
: [undefined] extends [TRoutes[K]]
|
|
211
|
+
? { name: K; params?: Exclude<TRoutes[K], undefined> }
|
|
212
|
+
: { name: K; params: TRoutes[K] };
|
|
213
|
+
}[Extract<keyof TRoutes, string>];
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Like `RouteEntry` but params are always optional partial — used for RESET
|
|
217
|
+
* payloads where you may want to omit params and fall back to screen defaults.
|
|
218
|
+
*
|
|
219
|
+
* @example
|
|
220
|
+
* ```ts
|
|
221
|
+
* type AppRoutes = { Home: undefined; Profile: { userId: string } };
|
|
222
|
+
*
|
|
223
|
+
* // Resolves to:
|
|
224
|
+
* // | { name: 'Home'; params?: undefined }
|
|
225
|
+
* // | { name: 'Profile'; params?: Partial<{ userId: string }> }
|
|
226
|
+
* type Entry = ResetRouteEntry<AppRoutes>;
|
|
227
|
+
*
|
|
228
|
+
* const routes: Entry[] = [
|
|
229
|
+
* { name: 'Home' },
|
|
230
|
+
* { name: 'Profile' }, // params omitted — screen uses its initialParams
|
|
231
|
+
* { name: 'Profile', params: { userId: '5' } }, // or supply them explicitly
|
|
232
|
+
* ];
|
|
233
|
+
* ```
|
|
234
|
+
*/
|
|
235
|
+
export type ResetRouteEntry<TRoutes extends Record<string, unknown>> = {
|
|
236
|
+
[K in Extract<keyof TRoutes, string>]: TRoutes[K] extends undefined
|
|
237
|
+
? { name: K; params?: undefined }
|
|
238
|
+
: { name: K; params?: Partial<TRoutes[K] & Record<string, unknown>> };
|
|
239
|
+
}[Extract<keyof TRoutes, string>];
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Props injected into every screen component: `navigation` and `route`, both
|
|
243
|
+
* typed to the specific route key `K` within `TRoutes`.
|
|
244
|
+
*
|
|
245
|
+
* **Recommended pattern** — create a single app-level alias so you only ever
|
|
246
|
+
* pass one generic per screen:
|
|
247
|
+
*
|
|
248
|
+
* ```ts
|
|
249
|
+
* // routes.ts — define once
|
|
250
|
+
* export type AppRoutes = {
|
|
251
|
+
* Home: undefined;
|
|
252
|
+
* Profile: { userId: string };
|
|
253
|
+
* Settings: { tab?: string };
|
|
254
|
+
* };
|
|
255
|
+
* export type AppScreenProps<K extends keyof AppRoutes> = ScreenProps<AppRoutes, K>;
|
|
256
|
+
* ```
|
|
257
|
+
*
|
|
258
|
+
* ```ts
|
|
259
|
+
* // ProfileScreen.tsx — required params
|
|
260
|
+
* import type { AppScreenProps } from './routes';
|
|
261
|
+
*
|
|
262
|
+
* function ProfileScreen({ route, navigation }: AppScreenProps<'Profile'>) {
|
|
263
|
+
* const { userId } = route.params; // string — fully typed
|
|
264
|
+
* navigation.navigate('Feed'); // optional route, no params
|
|
265
|
+
* navigation.navigate('Feed', { sort: 'top' }); // optional route, with params
|
|
266
|
+
* }
|
|
267
|
+
*
|
|
268
|
+
* // FeedScreen.tsx — optional params (T | undefined)
|
|
269
|
+
* function FeedScreen({ route, navigation }: AppScreenProps<'Feed'>) {
|
|
270
|
+
* const sort = route.params?.sort ?? 'latest'; // params may be undefined
|
|
271
|
+
* navigation.goBack();
|
|
272
|
+
* }
|
|
273
|
+
* ```
|
|
274
|
+
*
|
|
275
|
+
* You can also use `ScreenProps` directly without an alias:
|
|
276
|
+
*
|
|
277
|
+
* ```ts
|
|
278
|
+
* function HomeScreen({ navigation }: ScreenProps<AppRoutes, 'Home'>) {
|
|
279
|
+
* navigation.navigate('Profile', { userId: '42' });
|
|
280
|
+
* }
|
|
281
|
+
* ```
|
|
282
|
+
*/
|
|
283
|
+
export type ScreenProps<
|
|
284
|
+
TRoutes extends Record<string, unknown>,
|
|
285
|
+
K extends keyof TRoutes,
|
|
286
|
+
> = {
|
|
287
|
+
navigation: NavigationHelpers<TRoutes>;
|
|
288
|
+
route: RouteObject<TRoutes[K]>;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* A screen component typed to a specific route key.
|
|
293
|
+
* The component receives `navigation` and `route` props whose types are derived
|
|
294
|
+
* from the `TRoutes` map and the route key `K`.
|
|
295
|
+
*
|
|
296
|
+
* @example
|
|
297
|
+
* ```ts
|
|
298
|
+
* type AppRoutes = { Profile: { userId: string } };
|
|
299
|
+
*
|
|
300
|
+
* const ProfileScreen: ScreenComponent<AppRoutes, 'Profile'> = ({ navigation, route }) => {
|
|
301
|
+
* return <Text onPress={() => navigation.goBack()}>{route.params.userId}</Text>;
|
|
302
|
+
* };
|
|
303
|
+
* ```
|
|
304
|
+
*/
|
|
305
|
+
export type ScreenComponent<
|
|
306
|
+
TRoutes extends Record<string, unknown>,
|
|
307
|
+
K extends Extract<keyof TRoutes, string>,
|
|
308
|
+
> = (props: ScreenProps<TRoutes, K>) => JSX.Element;
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The screens map passed to `createStackNavigator`.
|
|
312
|
+
* Every key in `TRoutes` must be present. Each value is either a bare screen
|
|
313
|
+
* component or an object with `screen` + optional `initialParams`.
|
|
314
|
+
*
|
|
315
|
+
* `initialParams` is only valid for routes that have a params type; it is
|
|
316
|
+
* forbidden (`never`) for parameterless routes.
|
|
317
|
+
*
|
|
318
|
+
* @example
|
|
319
|
+
* ```ts
|
|
320
|
+
* type AppRoutes = {
|
|
321
|
+
* Home: undefined;
|
|
322
|
+
* Profile: { userId: string };
|
|
323
|
+
* Settings: { tab?: string };
|
|
324
|
+
* };
|
|
325
|
+
*
|
|
326
|
+
* const screens: ScreensMap<AppRoutes> = {
|
|
327
|
+
* // Bare component — no initial params:
|
|
328
|
+
* Home: HomeScreen,
|
|
329
|
+
*
|
|
330
|
+
* // Object form — also no initial params here:
|
|
331
|
+
* Profile: { screen: ProfileScreen },
|
|
332
|
+
*
|
|
333
|
+
* // With initial params that apply when the screen has none:
|
|
334
|
+
* Settings: { screen: SettingsScreen, initialParams: { tab: 'general' } },
|
|
335
|
+
* };
|
|
336
|
+
* ```
|
|
337
|
+
*/
|
|
338
|
+
export type ScreensMap<TRoutes extends Record<string, unknown>> = {
|
|
339
|
+
[K in Extract<keyof TRoutes, string>]:
|
|
340
|
+
| ScreenComponent<TRoutes, K>
|
|
341
|
+
| {
|
|
342
|
+
screen: ScreenComponent<TRoutes, K>;
|
|
343
|
+
initialParams?: [TRoutes[K]] extends [undefined]
|
|
344
|
+
? never
|
|
345
|
+
: Partial<Exclude<TRoutes[K], undefined> & Record<string, unknown>>;
|
|
346
|
+
};
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* A single route entry in the navigation stack.
|
|
351
|
+
* Loosely typed internally — type safety is enforced at action dispatch boundaries.
|
|
352
|
+
*
|
|
353
|
+
* @example
|
|
354
|
+
* ```ts
|
|
355
|
+
* const route: Route = {
|
|
356
|
+
* key: 'Profile-abc123',
|
|
357
|
+
* name: 'Profile',
|
|
358
|
+
* params: { userId: '42' },
|
|
359
|
+
* };
|
|
360
|
+
* ```
|
|
361
|
+
*/
|
|
362
|
+
export interface Route {
|
|
363
|
+
/** Unique key for this route instance (generated on push). */
|
|
364
|
+
key: string;
|
|
365
|
+
/** Name of the route, matching a key in `TRoutes`. */
|
|
366
|
+
name: string;
|
|
367
|
+
/** Route parameters. Typed loosely here; narrowed at screen boundaries. */
|
|
368
|
+
params?: unknown;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Snapshot of a stack navigator's state.
|
|
373
|
+
*
|
|
374
|
+
* @example
|
|
375
|
+
* ```ts
|
|
376
|
+
* const state: NavigationState<AppRoutes> = {
|
|
377
|
+
* type: 'stack',
|
|
378
|
+
* key: 'stack-1',
|
|
379
|
+
* routeNames: ['Home', 'Profile', 'Settings'],
|
|
380
|
+
* routes: [
|
|
381
|
+
* { key: 'Home-abc', name: 'Home' },
|
|
382
|
+
* { key: 'Profile-xyz', name: 'Profile', params: { userId: '42' } },
|
|
383
|
+
* ],
|
|
384
|
+
* index: 1, // Profile is currently focused
|
|
385
|
+
* stale: false,
|
|
386
|
+
* };
|
|
387
|
+
* ```
|
|
388
|
+
*/
|
|
389
|
+
export interface NavigationState<TRoutes extends Record<string, unknown> = Record<string, unknown>> {
|
|
390
|
+
/** Always `'stack'` for stack navigators. */
|
|
391
|
+
type: 'stack';
|
|
392
|
+
/** Unique key for this navigator instance. */
|
|
393
|
+
key: string;
|
|
394
|
+
/** Ordered list of all possible route names registered in this navigator. */
|
|
395
|
+
routeNames: Extract<keyof TRoutes, string>[];
|
|
396
|
+
/** Current route stack — the last entry is the top of the stack. */
|
|
397
|
+
routes: Route[];
|
|
398
|
+
/** Zero-based index of the currently focused route in `routes`. */
|
|
399
|
+
index: number;
|
|
400
|
+
/** `true` when the state has not yet been sanitized/rehydrated. */
|
|
401
|
+
stale: boolean;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Options accepted by `createStackNavigator`.
|
|
406
|
+
*
|
|
407
|
+
* @example
|
|
408
|
+
* ```ts
|
|
409
|
+
* type AppRoutes = {
|
|
410
|
+
* Home: undefined;
|
|
411
|
+
* Profile: { userId: string };
|
|
412
|
+
* };
|
|
413
|
+
*
|
|
414
|
+
* const Navigator = createStackNavigator<AppRoutes>({
|
|
415
|
+
* initialRouteName: 'Home', // shown first; defaults to the first key if omitted
|
|
416
|
+
* screens: {
|
|
417
|
+
* Home: HomeScreen,
|
|
418
|
+
* Profile: { screen: ProfileScreen, initialParams: { userId: 'guest' } },
|
|
419
|
+
* },
|
|
420
|
+
* });
|
|
421
|
+
* ```
|
|
422
|
+
*/
|
|
423
|
+
export interface StackNavigatorOptions<TRoutes extends Record<string, unknown>> {
|
|
424
|
+
/** The screens map — every route in `TRoutes` must be represented. */
|
|
425
|
+
screens: ScreensMap<TRoutes>;
|
|
426
|
+
/**
|
|
427
|
+
* Name of the route to show when the navigator first mounts.
|
|
428
|
+
* Defaults to the first key declared in `screens` if omitted.
|
|
429
|
+
*/
|
|
430
|
+
initialRouteName?: Extract<keyof TRoutes, string>;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* The route object passed to a screen component via `useRoute`.
|
|
435
|
+
* `TParams` is `undefined` for parameterless routes, in which case `params`
|
|
436
|
+
* is typed as `undefined` rather than an empty object.
|
|
437
|
+
*
|
|
438
|
+
* @example
|
|
439
|
+
* ```ts
|
|
440
|
+
* // In a parameterless screen:
|
|
441
|
+
* const route = useRoute<RouteObject<undefined>>();
|
|
442
|
+
* route.params; // undefined
|
|
443
|
+
*
|
|
444
|
+
* // In a screen with params:
|
|
445
|
+
* const route = useRoute<RouteObject<{ userId: string }>>();
|
|
446
|
+
* route.params.userId; // string
|
|
447
|
+
* ```
|
|
448
|
+
*/
|
|
449
|
+
export interface RouteObject<TParams = undefined> {
|
|
450
|
+
/** Unique key for this route instance. */
|
|
451
|
+
key: string;
|
|
452
|
+
/** Route name matching the key in the screens map. */
|
|
453
|
+
name: string;
|
|
454
|
+
/** Typed params — `undefined` for parameterless routes. */
|
|
455
|
+
params: TParams extends undefined ? undefined : TParams;
|
|
456
|
+
}
|