@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 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
+ }