diff --git a/skills/migrate-to-static-config/SKILL.md b/skills/migrate-to-static-config/SKILL.md index 1cd54a8..17675b5 100644 --- a/skills/migrate-to-static-config/SKILL.md +++ b/skills/migrate-to-static-config/SKILL.md @@ -1,6 +1,7 @@ --- name: migrate-to-static-config -description: Migrate React Navigation navigators from dynamic component based config to static object based config. +description: "Use when converting React Navigation navigators from JSX-based dynamic config to the static object-based config API. Covers createXNavigator({screens}), .with() wrappers, custom navigators using useNavigationBuilder, lazy loading migration, auth flows, linking, and type updates." +compatibility: "React Navigation 7.x+" --- # Migrating to Static Config @@ -13,6 +14,24 @@ Convert React Navigation navigators from JSX-based dynamic setup to static confi You are migrating screens from Dynamic API to the Static API in React Navigation. +Triggering symptoms: + +- Navigator files use ``, ``, or similar JSX patterns with `` children +- The codebase has a `NavigationContainer` wrapping a component-based root navigator +- Hand-written `ParamList` types are maintained alongside navigator definitions +- A centralized `linking.config.screens` object duplicates the navigator tree structure +- `getComponent` or render callbacks are used on `Screen` elements + +## Safety principle + +**Do not break working code.** If you are uncertain whether a change preserves behavior, do NOT apply it. Instead: + +1. Leave the code unchanged. +2. Add a comment or report to the user describing the suggested change, the uncertainty, and the risk. +3. Let the user decide whether to proceed. + +This applies to: type changes that may break call sites, wrapper removal that may lose behavior, linking changes that may break deep links, and any transformation where the before/after equivalence is not obvious. When in doubt, leave it and explain. + ## Adaptation policy Treat the patterns in this skill as canonical starting points, not an exhaustive list. The examples are meant to illustrate the core patterns. @@ -52,6 +71,123 @@ Ask for clarification when: - Migrating would require assumptions about which behavior is intentional. - It is unclear whether related helpers should be updated as part of the same change. +## Environment conflicts + +Project-level instructions (CLAUDE.md, .cursorrules, etc.) may restrict operations this skill requires. Handle conflicts as follows: + +- **Package updates blocked** -- If project instructions prohibit modifying package.json, skip the prerequisite version check. Note which packages may need updating and inform the user, but proceed with migration guidance using current versions. +- **Code modification restricted** -- If instructions restrict editing navigation files, produce the migration as a diff or structured plan instead of applying changes directly. Explain what would change and why. +- **Tool execution restricted** -- If instructions prohibit running npm/yarn commands, skip automated checks. Document which checks the user should run manually. +- **Conflicting conventions** -- If project conventions conflict with this skill's patterns (e.g., "always use dynamic config"), flag the conflict to the user and ask how to proceed. Do not silently follow either instruction. + +When in doubt, inform the user of the conflict and let them decide. Do not silently skip steps or silently override project instructions. + +## Common Mistakes + +These are patterns that commonly cause migration failures. Check for them proactively. + +### 1. Bottom-up migration breaks parent types + +`.with()` changes the return type from `React.ComponentType` to `TypedNavigatorStaticDecorated`. If the parent navigator is dynamic and uses `component={MyNavigator}`, it will produce a type error. + +**Check:** Before migrating any navigator, run: +```bash +grep -rn 'component={YourNavigatorName}\|component: YourNavigatorName' src/ +``` +If any match is in a dynamic parent, migrate the parent first or use `.getComponent()` on the static child. + +### 2. Using `React.lazy` for `getComponent` migration + +`React.lazy` requires async `import()` and adds Suspense fallback flashes for screens that previously loaded instantly via synchronous `require()`. Use the synchronous `lazyScreen` utility from the reference file instead. + +**Check:** After migration, run: +```bash +grep -rn 'React\.lazy' src/ --include='*.tsx' --include='*.ts' +``` +Any match in a navigator file is likely wrong. + +### 3. Hooks called directly in static config callbacks + +Static navigator config is created at module load time, not during render. Hooks in `options`, `listeners`, or `screenOptions` callbacks will crash. + +**Check:** Review every `options:` and `screenOptions:` in the static config. If any calls a hook (`use*`), move it to `.with()` or a `screenOptions` callback passed via `.with()`. + +### 4. Missing `linking` on screens with custom paths + +Auto-generated linking uses kebab-case of the screen name. Removing an explicit `linking` entry that had a custom path (e.g., `linking: 'contacts'`) silently changes the URL to the auto-generated path (e.g., `tab-contacts`), breaking existing deep links. + +**Check:** Compare old linking config entries against new screen-level `linking`. Every screen that had a custom path in the old `linking.config.screens` must have an explicit `linking` in static config. + +### 5. Outdated `@react-navigation/*` packages + +`.with()` was added in later 7.x versions. If packages are outdated, `.with()` does not exist and the migration fails. + +**Check:** Before starting, run: +```bash +npm ls @react-navigation/core @react-navigation/native @react-navigation/native-stack 2>/dev/null | grep -E '@react-navigation' +``` +Then compare against latest published versions: +```bash +npm view @react-navigation/core@latest version +npm view @react-navigation/native@latest version +``` + +### 6. Test files rendering navigator as JSX + +Test files that render `` break when the export changes from a component to a static config object. + +**Check:** After migration, run: +```bash +grep -rn ' + ``` + Should return zero matches for files that were migrated to static config. + +3. **No hand-written ParamList types remain (unless derived):** + ```bash + grep -rn 'ParamList\s*=' src/ + ``` + Every match should use `StaticParamList`, not hand-written types. + +4. **No old screen prop types remain in migrated screens:** + ```bash + grep -rn 'NativeStackScreenProps\|BottomTabScreenProps\|DrawerScreenProps' src/ + ``` + Migrated screens should use `StaticScreenProps` instead. + +5. **Root type augmentation exists:** + ```bash + grep -rn 'RootParamList extends' src/ + ``` + Exactly one `ReactNavigation.RootParamList` augmentation next to the root static navigator. + +6. **No `NavigationContainer` remains (if root was migrated):** + ```bash + grep -rn 'NavigationContainer' src/ + ``` + Should be replaced with `createStaticNavigation()`. + +7. **Linking preserved:** Compare old `linking.config.screens` paths against new screen-level `linking` entries. Every custom path must be explicitly present. + +8. **Wrapper behavior preserved:** For each migrated navigator that had wrapper JSX (providers, View wrappers, accessibility attributes, event handlers), verify the `.with()` callback reproduces the same DOM/component structure. Compare the old render output with the new one — every wrapper, style, and accessibility prop (`accessibilityViewIsModal`, `aria-modal`, `role`, etc.) must be present. + +9. **App runs and navigates correctly:** Manual smoke test — navigate to every migrated screen, test deep links, test back button behavior. + ## References Check `@react-navigation/native` in `package.json` first. diff --git a/skills/migrate-to-static-config/agents/openai.yaml b/skills/migrate-to-static-config/agents/openai.yaml index 523c537..a35766e 100644 --- a/skills/migrate-to-static-config/agents/openai.yaml +++ b/skills/migrate-to-static-config/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Migrate React Navigation to Static Config" - short_description: "Convert JSX navigators to static config" + short_description: "Convert JSX navigators to static object-based config" default_prompt: "Use $migrate-to-static-config to migrate React Navigation navigators from dynamic JSX to static config." diff --git a/skills/migrate-to-static-config/references/react-navigation-7.md b/skills/migrate-to-static-config/references/react-navigation-7.md index a8faa5b..218b7fa 100644 --- a/skills/migrate-to-static-config/references/react-navigation-7.md +++ b/skills/migrate-to-static-config/references/react-navigation-7.md @@ -5,15 +5,18 @@ Use this file only when `@react-navigation/native` is on `7.x`. ## Prerequisites - The project is using React Navigation 7.x. -- Before migrating any navigator, ensure `@react-navigation/*` packages in `package.json` are updated to the latest published 7.x version: - - Run `npm view package-name@latest version` for each `@react-navigation` package in `package.json` to check the latest version, for example `npm view @react-navigation/native@latest version`. - - If the versions are not up-to-date, stop and ask whether to update them. - - Once confirmed, update the versions in `package.json` and install them. - - Do not proceed with the migration unless versions are updated. +- Before migrating, check if `@react-navigation/*` packages are on the latest published 7.x version: + - Run `npm view package-name@latest version` for each `@react-navigation` package to check. + - If versions are outdated, recommend updating and explain why (newer 7.x versions added static config support and fixes). If the user declines or updates are blocked by project constraints, proceed with current versions and note any features that may be unavailable. ## Official reference -Fetch [llms.txt](https://reactnavigation.org/llms.txt) for a list of documentation links. During the migration, find the relevant link based on the topic and refer to the official docs when needed. +Fetch [llms.txt](https://reactnavigation.org/llms.txt) for a table of contents of all React Navigation documentation. During the migration, when you encounter an API, pattern, or type not fully covered in this reference, find the relevant link in llms.txt and fetch that specific page. Common mappings: + +- Custom navigators or routers -- fetch "Custom navigators" and "Custom routers" pages +- Static config API details -- fetch "Static configuration" page +- Type errors or TypeScript issues -- fetch "TypeScript" page +- Screen options for a specific navigator -- fetch that navigator's API page (e.g., "Stack Navigator") ## Structure @@ -26,7 +29,29 @@ Fetch [llms.txt](https://reactnavigation.org/llms.txt) for a list of documentati ## Workflow -### Identify static candidates +### Step 0: Check the parent navigator and navigator origin + +**Before classifying or editing any navigator, run these two prerequisite checks.** Do not skip them. + +**Check A — Parent navigator:** + +1. Find the parent navigator that renders this navigator as a `` component. +2. If the parent uses JSX-based dynamic config (`` with `` children, hooks for screenOptions, conditional rendering via JSX expressions), it is dynamic. +3. **If the parent is dynamic, classify this navigator as "keep dynamic"** unless the user explicitly wants to migrate bottom-up. Static navigators nested inside dynamic ones lose automatic linking and TypeScript type inference at the boundary (see "Mixing Static and Dynamic APIs" below). +4. If the user wants to proceed anyway, use the "Dynamic root navigator, static nested navigator" pattern: call `.getComponent()` on the static child and `createPathConfigForStaticNavigation()` for linking. +5. If you are migrating the root navigator, skip Check A — proceed to Check B. + +**Check B — Navigator origin:** + +1. Check if the navigator is produced by a **factory function** (a function that takes a screen list or config and returns a navigator component). Run: + ```bash + grep -rn 'function create.*Navigator\|const create.*Navigator' src/ --include='*.ts' --include='*.tsx' + ``` +2. If the navigator comes from a factory that generates **10+ navigators**, default to "keep dynamic" unless the user explicitly requests migration. Migrating factory-generated navigators requires duplicating the factory's shared wrapper logic (hooks, View wrappers, accessibility attributes) into individual `.with()` calls, which scales poorly and is error-prone. +3. If the factory generates **fewer than 10 navigators**, it is a candidate for the "Migrating navigator factories" pattern (see below). Proceed to classification. +4. If the navigator is NOT factory-generated, proceed to classification. + +### Classify the navigator A navigator is a static candidate if all its screens are known at build time. Classify it before editing code: @@ -45,6 +70,8 @@ A navigator is a static candidate if all its screens are known at build time. Cl - Navigation structure that depends on async data before the full route tree can be known - Child navigators whose parent navigator must stay dynamic and cannot represent the child as a static screen entry +When a navigator matches both "adaptation" and "keep dynamic" criteria, "keep dynamic" takes precedence. + Start the migration from the root navigator and work downwards. If the root navigator is not a static candidate, abort the migration unless the user explicitly wants to keep the root dynamic and migrate only nested navigators. ### Identify custom navigators @@ -52,7 +79,7 @@ Start the migration from the root navigator and work downwards. If the root navi A custom navigator uses the `useNavigationBuilder` hook. Before migration, ensure it uses same patterns for its types as official docs: ```tsx -import * as React from 'react'; +import * as React from "react"; import { View, Text, @@ -60,7 +87,7 @@ import { type StyleProp, type ViewStyle, StyleSheet, -} from 'react-native'; +} from "react-native"; import { createNavigatorFactory, CommonActions, @@ -75,7 +102,7 @@ import { type TypedNavigator, useNavigationBuilder, type NavigationProp, -} from '@react-navigation/native'; +} from "@react-navigation/native"; type MyNavigationConfig = { // Additional props accepted by the view @@ -165,7 +192,7 @@ const MyNavigator = createMyNavigator({ Home: HomeScreen, Profile: { screen: ProfileScreen, - options: { title: 'My Profile' }, + options: { title: "My Profile" }, }, }, }); @@ -175,6 +202,20 @@ The actual implementation of the navigator is not relevant to the migration. The If it doesn't accept a config object, update it to use the `createNavigatorFactory` and navigator API patterns shown above before migration. If it already uses the same patterns, there are no changes needed to the navigator implementation for static config migration. +#### Custom router options in static config + +If the custom navigator accepts additional router options beyond the standard set (e.g., `sidebarScreen`, `persistentScreens`, custom layout modes), these options cannot be expressed in the static config object directly. The static config API passes `screens`, `groups`, `screenOptions`, `screenListeners`, and `initialRouteName` to the navigator -- not custom router options. + +Options for handling custom router options: + +1. **Static values** -- Set them as default values in the navigator implementation itself. +2. **Dynamic values via `.with()`** -- Pass them as navigator props inside a `.with()` wrapper. +3. **Keep dynamic** -- If the custom router options must be computed from hooks or parent context that cannot be accessed via `.with()` + `useRoute()`, keep the navigator dynamic. + +When using `.with()` to pass custom router options, the flow is: the `.with()` wrapper passes props to `` → the navigator component receives them → it forwards them to `useNavigationBuilder` options → `useNavigationBuilder` passes non-standard options to the router factory. This means any prop you pass to `` inside `.with()` will reach the custom router, as long as the navigator component forwards it. + +If the navigator component does NOT forward the prop to `useNavigationBuilder` (e.g., it consumes it for rendering only), you may need to modify the navigator to thread it through. If neither `.with()` nor navigator modification can deliver the option to the router, keep the navigator dynamic. + ### Convert navigator JSX to static config Convert the existing navigator first, then introduce screen config objects only where a screen needs options, listeners, params, IDs, linking, or `if`. @@ -191,7 +232,7 @@ function MyStack() { ); @@ -207,7 +248,7 @@ const MyStack = createNativeStackNavigator({ Home: HomeScreen, Profile: { screen: ProfileScreen, - options: { title: 'My Profile' }, + options: { title: "My Profile" }, }, }, }); @@ -229,7 +270,7 @@ const MyStack = createNativeStackNavigator({ initialParams: {}, getId: ({ params }) => params.id, linking: { - path: 'pattern/:id', + path: "pattern/:id", parse: { id: Number }, stringify: { id: (value) => String(value) }, exact: true, @@ -297,11 +338,11 @@ Before: function RootStack() { return ( - + - + @@ -315,14 +356,14 @@ After: const RootStack = createNativeStackNavigator({ groups: { Card: { - screenOptions: { headerStyle: { backgroundColor: 'red' } }, + screenOptions: { headerStyle: { backgroundColor: "red" } }, screens: { Home: HomeScreen, Profile: ProfileScreen, }, }, Modal: { - screenOptions: { presentation: 'modal' }, + screenOptions: { presentation: "modal" }, screens: { Settings: SettingsScreen, }, @@ -379,7 +420,7 @@ function App() { )} @@ -415,6 +456,8 @@ const RootStack = createNativeStackNavigator({ ### Use `.with()` for wrappers, providers, and dynamic navigator props +`.with()` renders a regular React component. It can use any React hooks (including `useEffect`, `useCallback`, custom hooks), return early (e.g., `return null` for loading guards in nested navigators), and render arbitrary JSX (multiple nested wrappers, event handlers, refs, animated views). The `{ Navigator }` argument is the configured navigator component — render it wherever you would render ``. + If the dynamic navigator is rendered in a component that uses hooks for navigator-level behavior, or has wrappers around the mounted navigator, use `.with()` to provide this wrapper. This applies to navigator-level props such as `initialRouteName`, `backBehavior`, `screenOptions`, and `screenListeners` that are derived dynamically. #### Wrapping with a provider and dynamic props and options @@ -497,12 +540,12 @@ function MyStack() { @@ -526,13 +569,13 @@ const MyStack = createNativeStackNavigator({ { switch (route.name) { - case 'Home': + case "Home": return { - title: getSomething('First'), + title: getSomething("First"), }; - case 'Profile': + case "Profile": return { - title: getSomething('Second'), + title: getSomething("Second"), }; default: return {}; @@ -544,6 +587,73 @@ const MyStack = createNativeStackNavigator({ }); ``` +#### Dynamic `initialParams` from hooks + +If a screen's `initialParams` are computed from hooks or URL state at mount time, use `.with()` to compute them and pass via the `screenOptions` callback or a context provider. + +Before: + +```tsx +function MyStack() { + const computedId = useComputedId(); + + return ( + + + + ); +} +``` + +After: + +```tsx +const MyStack = createNativeStackNavigator({ + screens: { + Detail: DetailScreen, + }, +}).with(({ Navigator }) => { + const computedId = useComputedId(); + + return ; +}); +``` + +If different screens need different computed `initialParams`, use a context provider in `.with()` and read the context inside each screen component. + +#### Merging a static per-screen options map with dynamic base options + +If the codebase defines a static map of per-screen options and merges them with hook-derived base options at runtime, use a `screenOptions` callback inside `.with()`: + +```tsx +const OPTIONS_PER_SCREEN: Record = { + Settings: { animationTypeForReplace: "pop" }, + Profile: { gestureDirection: "vertical" }, +}; + +const MyStack = createNativeStackNavigator({ + screens: { + Settings: SettingsScreen, + Profile: ProfileScreen, + }, +}).with(({ Navigator }) => { + const baseOptions = useBaseScreenOptions(); + + return ( + ({ + ...baseOptions, + ...OPTIONS_PER_SCREEN[route.name], + })} + /> + ); +}); +``` + #### Convert render callbacks for screens Static config doesn't support render callbacks on screens. @@ -563,7 +673,7 @@ Before: After: ```tsx -const TokenContext = React.createContext(''); +const TokenContext = React.createContext(""); function ChatScreen() { const token = React.useContext(TokenContext); @@ -649,6 +759,49 @@ const MyStack = createNativeStackNavigator({ If multiple of these patterns are used on the same screen, use appropriate combinations of context and layout. +#### Migrating navigator factories + +A factory function that generates multiple navigators from a shared template (e.g., a function that takes a screen map and returns a configured navigator component) is not a custom navigator in the `useNavigationBuilder` sense — it is a wrapper-generator. + +To migrate a factory to static config: + +1. Convert each factory invocation to a separate `createXNavigator({ screens: ... })` call. +2. Extract shared wrapper logic into a reusable `.with()` callback. + +```tsx +// Shared wrapper for all factory-generated navigators +function withModalWrapper({ + Navigator, +}: { + Navigator: React.ComponentType; +}) { + const screenOptions = useModalScreenOptions(); + + return ( + + + + ); +} + +// Each factory call becomes a static navigator with the shared wrapper +const SettingsModal = createNativeStackNavigator({ + screens: { + Profile: ProfileScreen, + Preferences: PreferencesScreen, + }, +}).with(withModalWrapper); + +const ReportsModal = createNativeStackNavigator({ + screens: { + ReportList: ReportListScreen, + ReportDetail: ReportDetailScreen, + }, +}).with(withModalWrapper); +``` + +If the factory iterates over a static object to build screens (e.g., `Object.keys(screenMap)`), the screens ARE known at build time — this is a static candidate despite the iteration pattern. + #### Migrating `getComponent` lazy loading Static config uses a `screen` component and doesn't support `getComponent`. Use a custom utility to lazily render the screen: @@ -667,6 +820,8 @@ const lazyScreen = >( Place this utility in a shared file such as `utils/lazyScreen.ts` following the pattern of other shared utilities in the codebase. +This utility preserves synchronous loading semantics. Do not use `React.lazy(() => import(...))` for `getComponent` migration -- `React.lazy` requires async `import()` and introduces Suspense fallback flashes for screens that previously loaded instantly via synchronous `require()`. The `lazyScreen` utility above avoids this by calling the factory synchronously during render. + Then, replace `getComponent` with the lazy screen: Before: @@ -674,7 +829,7 @@ Before: ```tsx require('./SettingsScreen').default} + getComponent={() => require("./SettingsScreen").default} /> ``` @@ -684,8 +839,8 @@ After: const MyStack = createNativeStackNavigator({ screens: { Settings: { - screen: lazyScreen( - () => require('./SettingsScreen').default, + screen: lazyScreen( + () => require("./SettingsScreen").default, ), }, }, @@ -704,15 +859,15 @@ Before: ```tsx const linking = { - prefixes: ['https://example.com'], + prefixes: ["https://example.com"], config: { screens: { - Home: '', + Home: "", Profile: { - path: 'user/:id', + path: "user/:id", parse: { id: Number }, }, - Settings: 'settings', + Settings: "settings", }, }, }; @@ -725,12 +880,12 @@ const RootStack = createNativeStackNavigator({ screens: { Home: { screen: HomeScreen, - linking: '', // explicit root path; omit if this is the first leaf screen or the initialRouteName + linking: "", // explicit root path; omit if this is the first leaf screen or the initialRouteName }, Profile: { screen: ProfileScreen, linking: { - path: 'user/:id', + path: "user/:id", parse: { id: Number }, }, }, @@ -765,9 +920,9 @@ Before: function ProfileScreen({ navigation, route, -}: NativeStackScreenProps) { +}: NativeStackScreenProps) { const id = route.params.id; - navigation.navigate('Home'); + navigation.navigate("Home"); } ``` @@ -782,7 +937,7 @@ function ProfileScreen({ route }: ProfileScreenProps) { const navigation = useNavigation(); const id = route.params.id; - navigation.navigate('Home'); + navigation.navigate("Home"); } ``` @@ -795,7 +950,7 @@ type RootStackParamList = StaticParamList; type ProfileNavigationProp = NativeStackNavigationProp< RootStackParamList, - 'Profile' + "Profile" >; const navigation = useNavigation(); @@ -816,6 +971,16 @@ If a static navigator nests a dynamic navigator, annotate the dynamic navigator For the root navigator, keep the single source of truth in the `RootParamList` augmentation shown below. +#### Custom screen prop types + +If the codebase uses custom screen prop types from a custom navigator (e.g., `PlatformStackScreenProps` instead of `NativeStackScreenProps`), migrate them alongside the standard types: + +1. Replace the custom screen prop type with `StaticScreenProps` for param typing. +2. If the custom type provides navigator-specific navigation methods, use `useNavigation()` with a manual type annotation as a temporary bridge. +3. Update all screen files that reference the old custom type. + +If many screen files (10+) reference the custom type, consider a codemod or find-and-replace to update them in bulk rather than manually. + Avoid circular dependencies by: - Using `StaticScreenProps` for screen params instead of shared hand-written param lists @@ -864,7 +1029,7 @@ const RootStack = createNativeStackNavigator({ Article: { screen: ArticleScreen, linking: { - path: 'article/:date', + path: "article/:date", parse: { date: (date: string) => new Date(date), }, @@ -894,8 +1059,8 @@ const Stack = createNativeStackNavigator(); function ArticleScreen({ navigation, route, -}: NativeStackScreenProps) { - return