React Native to TV: Quick Start Guide
You know React Native. Here's what's different for TV.
Official Docs:
- Expo TV: https://docs.expo.dev/guides/building-for-tv/
- Vega SDK: https://developer.amazon.com/docs/vega/latest/build-apps-overview.html
The Big 5 Differences from Mobile
1. No Touch - Everything is Remote/D-pad
On mobile: Users tap buttons On TV: Users navigate with D-pad (up/down/left/right) + select
// Mobile: TouchableOpacity, Pressable just work
// TV: You need spatial navigation
import { SpatialNavigationNode } from 'react-tv-space-navigation';
const TVButton = ({ onPress, children }) => {
return (
<SpatialNavigationNode onSelect={onPress}>
{({ isFocused }) => (
<View style={[styles.button, isFocused && styles.focused]}>
{children}
</View>
)}
</SpatialNavigationNode>
);
};Key library: react-tv-space-navigation
Important: v6.0.0+ uses a different API than v5.x:
- v6:
<SpatialNavigationRoot>wrapper +<SpatialNavigationNode>components - v5:
SpatialNavigation.init()+useFocusable()hook
2. Focus is King
Every interactive element MUST have a visible focus state. Users can't tap what they can't see.
const styles = StyleSheet.create({
button: {
padding: 16,
backgroundColor: '#333',
},
// CRITICAL: Always show what's focused
focused: {
backgroundColor: '#555',
borderWidth: 3,
borderColor: '#fff',
transform: [{ scale: 1.05 }],
},
});Focus Indicators (Use at least 2):
- ✅ Border (3px+ white/colored)
- ✅ Scale (1.05x - 1.1x)
- ✅ Background color change
- ✅ Shadow/glow effect
- ✅ Opacity change
Testing Focus:
- Navigate with D-pad/arrow keys
- Focused element should be immediately obvious
- Test in a dark room (10-foot viewing distance)
- All interactive elements must be reachable button: {padding: 16, backgroundColor: '#333',}, // CRITICAL: Always show what's focused focused: {backgroundColor: '#555', borderWidth: 3, borderColor: '#fff', transform: [{scale: 1.05}],},});
### 3. 10-Foot UI (Bigger Everything)
Users sit 10 feet away. What works on mobile is too small.
| Element | Mobile | TV |
|---------|--------|-----|
| Body text | 14-16px | 24-32px |
| Buttons | 44px height | 60-80px height |
| Touch targets | 44x44px | 80x80px+ |
| Margins | 16px | 48px+ |
### 4. Safe Zones (TV Overscan)
TVs crop edges. Keep content away from borders.
const safeZones = { horizontal: 48, // Left/right padding vertical: 27, // Top/bottom padding };
### 5. Landscape Only
TV apps are always landscape. Configure in app.json:
{ "expo": { "orientation": "landscape" } }
---
## Platform Quick Reference
| Platform | Technology | Remote Events |
| --- | --- | --- |
| Android TV | Expo + react-native-tvos | KeyEvent |
| Apple TV | Expo + react-native-tvos | TVEventHandler |
| Fire TV FOS | Expo + react-native-tvos | KeyEvent |
| Fire TV Vega | Amazon Vega SDK | Kepler TVEventHandler |
| Web TV | React Native Web | Keyboard events |
---
## Prerequisites
### For Android TV / Fire TV (Fire OS)
- Node.js LTS (macOS or Linux)
- Android Studio Iguana or later
- Android SDK API 31+ with TV system image
- Android TV emulator configured
### For Apple TV (tvOS)
- Node.js LTS on macOS
- Xcode 16+
- tvOS SDK 17+ (install via `xcodebuild -downloadAllPlatforms`)
### For Fire TV Vega OS
- Amazon Vega SDK installed (see Vega section below)
---
## Complete Project Setup
This guide creates a production-ready monorepo structure supporting all TV platforms.
> **Claude will ask for your app name and package ID when setting up.**
### Step 1: Create Monorepo Structure
Create project root
mkdir <PROJECT_NAME> && cd <PROJECT_NAME>
Initialize Yarn
yarn init -y yarn set version stable
Create directory structure
mkdir -p apps packages/shared-ui/src
### Step 2: Configure Root package.json
Create `package.json`:
{ "name": "@<PROJECT_NAME>/monorepo", "version": "1.0.0", "private": true, "packageManager": "yarn@4.5.0", "workspaces": [ "apps/*", "packages/*" ], "scripts": { "dev": "yarn workspace @<PROJECT_NAME>/expo-multi-tv start", "dev:android": "yarn workspace @<PROJECT_NAME>/expo-multi-tv android", "dev:ios": "yarn workspace @<PROJECT_NAME>/expo-multi-tv ios", "dev:web": "yarn workspace @<PROJECT_NAME>/expo-multi-tv web", "dev:vega": "yarn workspace @<PROJECT_NAME>/vega start", "build:vega": "yarn workspace @<PROJECT_NAME>/vega build", "lint:all": "yarn workspaces foreach -pt run lint", "typecheck": "yarn workspaces foreach -pt run typecheck" }, "resolutions": { "metro-source-map": "0.80.12" } }
> **CRITICAL**: The `metro-source-map` resolution fixes babel plugin errors in monorepos.
### Step 3: Create Shared TypeScript Config
Create `tsconfig.base.json`:
{ "compilerOptions": { "target": "ESNext", "module": "CommonJS", "lib": ["ES2021"], "jsx": "react-native", "strict": true, "moduleResolution": "node", "allowSyntheticDefaultImports": true, "esModuleInterop": true, "skipLibCheck": true, "resolveJsonModule": true, "declaration": true, "declarationMap": true } }
### Step 4: Create Root Babel Config
Create `babel.config.js`:
const path = require('path');
module.exports = function (api) { api.cache(true);
let reanimatedPlugin; try { reanimatedPlugin = require.resolve('react-native-reanimated/plugin', { paths: [path.join(__dirname, 'apps/expo-multi-tv/node_modules')] }); } catch (e) { reanimatedPlugin = null; }
return { presets: ['babel-preset-expo'], plugins: reanimatedPlugin ? [reanimatedPlugin] : [], }; };
### Step 5: Create Expo TV App
cd apps
Create Expo TV project
npx create-expo-app@latest expo-multi-tv -e with-tv
cd expo-multi-tv
Update `apps/expo-multi-tv/package.json`:
{ "name": "@<PROJECT_NAME>/expo-multi-tv", "dependencies": { "@<PROJECT_NAME>/shared-ui": "*" } }
Create `apps/expo-multi-tv/metro.config.js`:
const { getDefaultConfig } = require('expo/metro-config'); const path = require('path');
const projectRoot = __dirname; const workspaceRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
config.watchFolders = [workspaceRoot]; config.resolver.nodeModulesPaths = [ path.resolve(projectRoot, 'node_modules'), path.resolve(workspaceRoot, 'node_modules'), ];
module.exports = config;
### Step 6: Add Vega App (Fire TV Vega OS)
Create and enter vega directory first (files generate in current directory)
mkdir -p apps/vega cd apps/vega
Generate Vega project
vega project generate -n <APP_NAME> --template helloWorld
npm install
Update `apps/vega/package.json`:
{ "name": "@<PROJECT_NAME>/vega", "scripts": { "build:debug": "react-native build-kepler --build-type Debug", "build:release": "react-native build-kepler --build-type Release" }, "dependencies": { "@<PROJECT_NAME>/shared-ui": "*", "react-tv-space-navigation": "^6.0.0-beta1" } }
**CRITICAL: Update `apps/vega/manifest.toml`** - Add the `[processes]` section or the app will crash on launch:
schema-version = 1
[package] title = "My App" version = "0.1.0" id = "com.mycompany.myapp"
[components] [[components.interactive]] id = "com.mycompany.myapp.main" runtime-module = "/com.amazon.kepler.keplerscript.runtime.loader_2@IKeplerScript_2_0" launch-type = "singleton" categories = ["com.amazon.category.main"]
REQUIRED: Without this section, the app crashes immediately on launch
[processes] [[processes.group]] component-ids = ["com.mycompany.myapp.main"]
Create `apps/vega/metro.config.js`:
const path = require('path'); const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const projectRoot = __dirname; const workspaceRoot = path.resolve(projectRoot, '../..');
const config = { watchFolders: [workspaceRoot], resolver: { nodeModulesPaths: [ path.resolve(projectRoot, 'node_modules'), path.resolve(workspaceRoot, 'node_modules'), ], sourceExts: ['kepler.tsx', 'kepler.ts', 'tsx', 'ts', 'js', 'jsx', 'json'], }, };
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
### Step 6b: Configure Spatial Navigation for Vega
`react-tv-space-navigation` works with Vega but requires a remote control configuration.
**Create `packages/shared-ui/src/app/remote-control/SupportedKeys.ts`:**
export enum SupportedKeys { Up = 'up', Down = 'down', Left = 'left', Right = 'right', Enter = 'enter', Back = 'back', PlayPause = 'playPause', Rewind = 'rewind', FastForward = 'fastForward', }
**Create `packages/shared-ui/src/app/remote-control/RemoteControlManager.kepler.ts`:**
import { SupportedKeys } from './SupportedKeys';
const EVENT_MAP: Record<string, SupportedKeys> = { left: SupportedKeys.Left, right: SupportedKeys.Right, down: SupportedKeys.Down, up: SupportedKeys.Up, select: SupportedKeys.Enter, back: SupportedKeys.Back, };
class RemoteControlManager { private listeners = new Set<(e: SupportedKeys) => void>(); constructor() { const { TVEventHandler } = require('react-native'); new TVEventHandler().enable(this, (_: any, e: any) => { if (e.eventKeyAction === 0 || e.eventKeyAction === undefined) { const key = EVENT_MAP[e.eventType]; if (key) this.listeners.forEach(l => l(key)); } }); } addKeydownListener = (l: (e: SupportedKeys) => void) => { this.listeners.add(l); return l; }; removeKeydownListener = (l: (e: SupportedKeys) => void) => { this.listeners.delete(l); }; } export default new RemoteControlManager();
**Create `packages/shared-ui/src/app/configureRemoteControl.ts`:**
import { Directions, SpatialNavigation } from 'react-tv-space-navigation'; import { SupportedKeys } from './remote-control/SupportedKeys'; import RemoteControlManager from './remote-control/RemoteControlManager';
SpatialNavigation.configureRemoteControl({ remoteControlSubscriber: (callback) => { const map = { [SupportedKeys.Right]: Directions.RIGHT, [SupportedKeys.Left]: Directions.LEFT, [SupportedKeys.Up]: Directions.UP, [SupportedKeys.Down]: Directions.DOWN, [SupportedKeys.Enter]: Directions.ENTER, }; return RemoteControlManager.addKeydownListener((k) => callback(map[k] ?? null)); }, remoteControlUnsubscriber: (l) => RemoteControlManager.removeKeydownListener(l), });
**Import in Vega App.tsx:**
import '../../../packages/shared-ui/src/app/configureRemoteControl';
### Step 7: Create Shared UI Package
Create `packages/shared-ui/package.json`:
{ "name": "@<PROJECT_NAME>/shared-ui", "version": "1.0.0", "main": "src/index.ts", "types": "src/index.ts", "peerDependencies": { "react": "*", "react-native": "*" } }
Create `packages/shared-ui/tsconfig.json`:
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist" }, "include": ["src/**/*"] }
Create `packages/shared-ui/src/index.ts`:
// Theme export * from './theme';
// Components export * from './components/FocusablePressable';
// Hooks export * from './hooks/useScale';
### Step 8: Create Theme System
Create `packages/shared-ui/src/theme/index.ts`:
export const colors = { primary: '#E50914', background: '#141414', card: '#2F2F2F', text: '#FFFFFF', textSecondary: '#B3B3B3', };
export const spacing = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48, };
export const safeZones = { horizontal: 48, vertical: 27, };
### Step 9: Create useScale Hook
Create `packages/shared-ui/src/hooks/useScale.ts`:
import { Dimensions } from 'react-native';
const BASE_WIDTH = 1920; const BASE_HEIGHT = 1080;
export const useScale = () => { const { width, height } = Dimensions.get('window'); const scaleWidth = width / BASE_WIDTH; const scaleHeight = height / BASE_HEIGHT; const scale = Math.min(scaleWidth, scaleHeight);
return { scale: (size: number) => size * scale, width, height, }; };
### Step 10: Create FocusablePressable Component
Create `packages/shared-ui/src/components/FocusablePressable.tsx`:
import React from 'react'; import { Pressable, StyleSheet, ViewStyle } from 'react-native'; import { SpatialNavigationNode } from 'react-tv-space-navigation';
interface FocusablePressableProps { children: React.ReactNode; onPress?: () => void; style?: ViewStyle; focusedStyle?: ViewStyle; }
export const FocusablePressable: React.FC<FocusablePressableProps> = ({ children, onPress, style, focusedStyle, }) => { return ( <SpatialNavigationNode onSelect={onPress}> {({ isFocused }) => ( <Pressable onPress={onPress} style={[ styles.container, style, isFocused && styles.focused, isFocused && focusedStyle, ]} > {children} </Pressable> )} </SpatialNavigationNode> ); };
const styles = StyleSheet.create({ container: { padding: 16, }, focused: { borderWidth: 3, borderColor: '#fff', transform: [{ scale: 1.05 }], }, });
**Important**: Pass `onPress` to both `SpatialNavigationNode` (for remote/D-pad) AND `Pressable` (for touch/click).
**Focus Highlighting**: The `isFocused` prop from the render function is used to apply visual feedback:
{({ isFocused }) => ( <Pressable onPress={onPress} style={[ styles.container, style, isFocused && styles.focused, // Apply focus styles isFocused && focusedStyle, // Allow custom focus styles ]} > {children} </Pressable> )}
The default focus styles provide:
- **3px white border** - Clear visual boundary
- **1.05x scale** - Subtle size increase
- **Custom focusedStyle prop** - Override with app-specific styles
**Best Practices for Focus:**
- Always use high-contrast colors (white/yellow on dark backgrounds)
- Combine multiple indicators (border + scale + color)
- Test on actual TV hardware (emulators may not show true visibility)
- Ensure focus is visible from 10 feet away
### Step 11: Install Dependencies and Run
From project root
cd ../.. yarn install
Run on platforms
yarn dev:android # Android TV / Fire TV FOS yarn dev:ios # Apple TV yarn dev:web # Web TV yarn dev:vega # Fire TV Vega OS
---
## Final Project Structure
<PROJECT_NAME>/ ├── apps/ │ ├── expo-multi-tv/ # Android TV, Apple TV, Fire TV FOS, Web │ │ ├── App.tsx # Main app entry point │ │ ├── app.json │ │ ├── metro.config.js │ │ └── package.json │ └── vega/ # Fire TV Vega OS │ ├── App.tsx │ ├── metro.config.js │ └── package.json ├── packages/ │ └── shared-ui/ # Shared components & utilities │ ├── src/ │ │ ├── components/ │ │ ├── hooks/ │ │ ├── theme/ │ │ └── index.ts │ ├── package.json │ └── tsconfig.json ├── babel.config.js ├── package.json ├── tsconfig.base.json └── yarn.lock
---
## Platform-Specific File Resolution
Metro bundler automatically resolves platform files:
- `.kepler.ts/tsx` - Fire TV Vega OS (highest priority for Vega)
- `.android.ts/tsx` - Android TV & Fire TV Fire OS
- `.ios.ts/tsx` - Apple TV (tvOS)
- `.web.ts/tsx` - Web platforms
- `.ts/tsx` - Default/shared implementation
**Example:**
RemoteControlManager.android.ts → Android TV / Fire TV FOS RemoteControlManager.ios.ts → Apple TV RemoteControlManager.kepler.ts → Fire TV Vega
---
## Build Commands
### Expo TV App (Android TV, Apple TV, Fire TV FOS, Web)
yarn dev:android # Run on Android TV emulator yarn dev:ios # Run on Apple TV simulator yarn dev:web # Run in browser
Production builds
npx expo build:android npx expo build:ios npx expo export --platform web
### Vega App (Fire TV Vega OS)
yarn dev:vega # Start Metro for Vega
Build VPKG
vega build --arch armv7 --buildType release # Fire TV Stick vega build --arch aarch64 --buildType release # Fire TV (ARM64) vega build --arch x86_64 --buildType debug # Virtual devices
Deploy
vega device list vega install --vpkg build/armv7-release/<APP_NAME>.vpkg vega run --packageId <PACKAGE_ID>
---
## Common Build Issues & Solutions
### Java Version Error
**Error:** `Android Gradle plugin requires Java 17 to run. You are currently using Java 11.`
**Solution:** Create `android/gradle.properties`:
org.gradle.java.home=/path/to/java17 org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m hermesEnabled=true android.useAndroidX=true android.enableJetifier=true
### Metro Source Map Error (Monorepo)
**Error:** `Cannot find module 'metro-source-map/private/source-map'`
**Cause:** Expo's metro package expects a `private/` directory that doesn't exist in metro-source-map 0.80.x
**Solution:** Create symlinks after `yarn install`:
cd node_modules/metro-source-map mkdir -p private/Consumer ln -sf ../src/source-map.js private/source-map.js for file in src/Consumer/*.js; do ln -sf "../../$file" "private/Consumer/$(basename $file)" done
**Better Solution:** Add to `package.json` scripts:
{ "scripts": { "postinstall": "node scripts/fix-metro-source-map.js" } }
Create `scripts/fix-metro-source-map.js`:
const fs = require('fs'); const path = require('path');
const metroSourceMapPath = path.join(__dirname, '../node_modules/metro-source-map'); const privatePath = path.join(metroSourceMapPath, 'private'); const consumerPath = path.join(privatePath, 'Consumer');
if (!fs.existsSync(privatePath)) { fs.mkdirSync(privatePath, { recursive: true }); } if (!fs.existsSync(consumerPath)) { fs.mkdirSync(consumerPath, { recursive: true }); }
// Create source-map.js symlink const sourceMapSrc = path.join(metroSourceMapPath, 'src/source-map.js'); const sourceMapDest = path.join(privatePath, 'source-map.js'); if (!fs.existsSync(sourceMapDest)) { fs.symlinkSync(path.relative(privatePath, sourceMapSrc), sourceMapDest); }
// Create Consumer symlinks const consumerSrcPath = path.join(metroSourceMapPath, 'src/Consumer'); const files = fs.readdirSync(consumerSrcPath).filter(f => f.endsWith('.js')); files.forEach(file => { const src = path.join(consumerSrcPath, file); const dest = path.join(consumerPath, file); if (!fs.existsSync(dest)) { fs.symlinkSync(path.relative(consumerPath, src), dest); } });
console.log('✓ Fixed metro-source-map private paths');
### App Entry Point Error (Monorepo)
**Error:** `Unable to resolve "../../App" from "node_modules/expo/AppEntry.js"`
**Cause:** In monorepo, Expo's default AppEntry.js looks for App.tsx at wrong path
**Solution:** Create `index.js` in app root:
import { registerRootComponent } from 'expo'; import App from './App';
registerRootComponent(App);
This overrides Expo's default entry point and uses the correct relative path.
### react-tv-space-navigation API Error
**Error:** `TypeError: SpatialNavigation.init is not a function (it is undefined)`
**Cause:** react-tv-space-navigation v6.0.0+ has breaking API changes from v5.x
**v5.x API (OLD - Don't use):**
import { SpatialNavigation, useFocusable } from 'react-tv-space-navigation';
// Initialize SpatialNavigation.init({ debug: true });
// Use hook const { ref, focused } = useFocusable({ onEnterPress: onPress });
**v6.0.0+ API (NEW - Use this):**
import { SpatialNavigationRoot, SpatialNavigationNode } from 'react-tv-space-navigation';
// Wrap app <SpatialNavigationRoot> <App /> </SpatialNavigationRoot>
// Use component with render prop <SpatialNavigationNode onSelect={onPress}> {({ isFocused }) => ( <View style={isFocused && styles.focused}> {children} </View> )} </SpatialNavigationNode>
**Migration Steps:**
1. Remove `SpatialNavigation.init()` calls
2. Wrap root component in `<SpatialNavigationRoot>`
3. Replace `useFocusable()` with `<SpatialNavigationNode>` render prop
4. Change `focused` to `isFocused` in render prop
5. Change `onEnterPress` to `onSelect`
### Hermes Configuration Missing
**Error:** `Could not get unknown property 'hermesEnabled'`
**Solution:** Add to `android/gradle.properties`:
hermesEnabled=true
---
## Focus Management Troubleshooting
### Focus Not Visible
**Problem:** Can't see which element is focused
**Solutions:**
1. Increase border width: `borderWidth: 4` or higher
2. Use high-contrast colors: `borderColor: '#FFFF00'` (yellow)
3. Add multiple indicators:
focused: { borderWidth: 4, borderColor: '#fff', transform: [{ scale: 1.1 }], backgroundColor: '#444', shadowColor: '#fff', shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.8, shadowRadius: 10, }
### Focus Not Moving Between Elements
**Problem:** D-pad navigation doesn't move focus
**Causes & Solutions:**
1. **Missing SpatialNavigationRoot**: Wrap entire app
2. **Elements not aligned**: Ensure buttons are in proper layout (Row/Column)
3. **Check console**: Look for spatial navigation warnings
4. **Test with multiple elements**: Need at least 2 focusable items
### Button Press Not Working
**Problem:** Focused button doesn't respond to Enter/Select
**Solution:** Ensure both `onSelect` AND `onPress` are set:
<SpatialNavigationNode onSelect={handlePress}> {({ isFocused }) => ( <Pressable onPress={handlePress}> {/* Both needed! */}
---
## Common Mistakes (Don't Do These)
### ❌ Using Pressable without Focus Management
// WRONG <Pressable onPress={handlePress}><Text>Click</Text></Pressable>
// RIGHT - Use FocusablePressable from shared-ui <FocusablePressable onPress={handlePress}><Text>Select</Text></FocusablePressable>
### ❌ Forgetting Safe Zones
// WRONG - Content at edges <View style={{ position: 'absolute', top: 0, left: 0 }}>
// RIGHT <View style={{ position: 'absolute', top: 27, left: 48 }}>
### ❌ Small Touch Targets
// WRONG - Too small for TV <View style={{ width: 44, height: 44 }} />
// RIGHT <View style={{ width: 80, height: 80 }} />
---
## Vega Troubleshooting
### App Crashes Immediately on Launch (Black Screen)
**Symptom:** App installs successfully, "Successfully launched" message appears, but app immediately crashes with black screen and no error.
**Cause:** Missing `[processes]` section in `manifest.toml`
**Fix:** Add to `apps/vega/manifest.toml`:
[processes] [[processes.group]] component-ids = ["com.yourcompany.yourapp.main"]
### Running on Vega Virtual Device
Start simulator
vega simulator
Check device is ready
vega device list
Build and run
yarn build:release # or build:debug vega run-app build/aarch64-release/<APP_NAME>_aarch64.vpkg <PACKAGE_ID>
Check if running
vega device is-app-running --appName <PACKAGE_ID>
---
## Testing Your TV App
### Android TV
In Android Studio: Tools > Device Manager > Create Device > TV
yarn dev:android
### Apple TV
In Xcode: Window > Devices and Simulators > Simulators
yarn dev:ios
### Web (with keyboard)
yarn dev:web
Navigate with arrow keys, Enter to select
### Physical Devices
- Android TV: Enable developer mode, connect via ADB
- Fire TV: Enable ADB debugging in Settings
- Apple TV: Connect via Xcode
---
## Next Steps
1. **Add screens** - Create screens in `packages/shared-ui/src/screens/`
2. **Add navigation** - Set up React Navigation in shared-ui
3. **Add video player** - Use react-native-video (Expo) or VideoView (Vega)
4. **Handle remote events** - Create platform-specific RemoteControlManagers
5. **Test on real hardware** - Emulators don't show real performance
## Need More?
- **Full knowledge base**: Use the `multi-tv-builder` skill
- **Apple TV issues**: Use the `apple-tv-troubleshooter` skill
- **Movie/TV data API**: Use the `tmdb-integration` skill
## Reference Implementation
For a complete working example with all features implemented:
- **Sample repo**: [https://github.com/AmazonAppDev/react-native-multi-tv-app-sample](https://github.com/AmazonAppDev/react-native-multi-tv-app-sample)
Use this as a reference to see advanced patterns like video playback, navigation, and remote control handling.
---
# Android TV / Fire TV Runtime Troubleshooting
## TypeError: Cannot read property 'displayName' of undefined
**Symptoms:**
- App builds successfully but crashes immediately
- Metro shows: `ERROR TypeError: Cannot read property 'displayName' of undefined`
- App returns to launcher after brief flash
**Common Causes & Fixes:**
1. **Wrong import in index.js** (Most Common) `// ❌ WRONG - Named import when App uses default export import {App} from './App'; // ✅ CORRECT - Default import import App from './App';`
2. **Metro cache corruption** `pkill -f "expo" 2>/dev/null || true rm -rf node_modules/.cache /tmp/metro-* /tmp/haste-map-* npx expo start --clear`
3. **react-tv-space-navigation v6 missing configuration** `// Create src/configureRemoteControl.ts import {SpatialNavigation} from 'react-tv-space-navigation'; SpatialNavigation.configureRemoteControl({remoteControlSubscriber: (callback) => () => {}, remoteControlUnsubscriber: () => {},});`
## Metro Bundler Connection Issues
**Symptoms:**
- `Couldn't connect to "ws://localhost:8081/message..."`
- App shows loading screen indefinitely
**Fixes:**
Set up ADB reverse port forwarding
adb reverse tcp:8081 tcp:8081
Verify emulator connection
adb devices -l
Restart ADB if stale
adb kill-server && adb start-server
## Android TV Emulator Quick Commands
Start emulator
emulator -avd Android_TV_720p -no-snapshot-load &
Wait for boot
adb wait-for-device
Force restart app
adb shell am force-stop <package_name> adb shell am start -n <package_name>/.MainActivity
Check logs for JS errors
adb logcat -d | grep -iE "(ReactNativeJS|error)" | tail -30
## Expo Go vs Development Builds
**Important:** TV apps should use **development builds**, not Expo Go.
❌ May cause SDK version issues on TV
npx expo start
✅ Correct for TV development
npx expo run:android
or
npx expo start --dev-client
---
# Vega in Monorepo - Critical Setup
## Port Forwarding Required
Vega virtual devices need reverse port forwarding to connect to Metro:
REQUIRED before launching app
vega device start-port-forwarding --port 8081 --forward false
Without this, the app shows a black screen because it can't fetch the JS bundle.
## React Version Conflicts (Expo + Vega)
If your monorepo has both Expo (React 19) and Vega (React 18), you'll get:
TypeError: Cannot read property 'ReactCurrentOwner' of undefined
**Fix:** Move root React packages before running Vega:
mv node_modules/react node_modules/react.bak mv node_modules/react-native node_modules/react-native.bak
## Node.js 23 Compatibility
Node 23 breaks Metro's package exports. Fix by removing `exports` field:
// Run this after yarn install const fs = require('fs'); ['metro', 'metro-source-map', 'metro-transform-worker', 'metro-runtime'].forEach(pkg => { const p = node_modules/${pkg}/package.json; if (fs.existsSync(p)) { const json = JSON.parse(fs.readFileSync(p)); delete json.exports; fs.writeFileSync(p, JSON.stringify(json, null, 2)); } });
## Complete Vega Launch Sequence
1. Fix Metro (Node 23 only)
node fix-metro-exports.js
2. Move root React (monorepo only)
mv node_modules/react node_modules/react.bak
3. Start Metro
cd apps/vega && npm start
4. Port forwarding (new terminal)
vega device start-port-forwarding --port 8081 --forward false
5. Launch
vega device launch-app --appName com.yourcompany.app
6. Verify
vega device running-apps | grep yourapp