# FocusRequester Safety Patterns

## Problem

"FocusRequester not initialized" — fires constantly during D-pad navigation on multiple screens. Composables call `requestFocus()` before the node is attached, or the FocusRequester is recreated outside `remember {}`.

## Root Cause Analysis

Compose's `FocusRequester.requestFocus()` throws `IllegalStateException("FocusRequester is not initialized")` when the target composable **has not yet been composed or laid out**. On Android TV (D-pad), this happens constantly because:

1. **Race with composition**: A `LaunchedEffect(Unit)` fires immediately when the composable enters composition, but the `.focusRequester(requester)` modifier hasn't been applied yet — the node needs a full layout pass first.

2. **Sidebar `focusProperties` traversal**: When the D-pad engine follows `focusProperties { right = entryRequester }`, if `entryRequester` targets an unattached node, Compose throws from its internal focus search — this is NOT catchable with `runCatching` around a `requestFocus()` call because it's the engine, not user code, making the call.

3. **Recreated FocusRequesters**: If a screen creates `FocusRequester()` outside `remember {}` (common in lambdas, lazy lists, or `derivedStateOf`), the old requester becomes orphaned. The focus engine holds a reference to the old one; D-pad navigation follows that stale reference → crash.

## Fix Patterns Applied June 2026

### Pattern 1: `requestFocusAfterFrames()` (preferred for LaunchedEffect)

```kotlin
// BEFORE — unsafe, races with layout
LaunchedEffect(Unit) {
    contentFocusRequester.requestFocus()
}

// AFTER — safe, waits 2 frames + retries 4x
LaunchedEffect(Unit) {
    contentFocusRequester.requestFocusAfterFrames()
}
```

The extension function (in `FocusRestoreUtils.kt`) does:
```kotlin
suspend fun FocusRequester.requestFocusAfterFrames(frames: Int = 2) {
    repeat(frames.coerceAtLeast(0)) { withFrameNanos { } }  // wait N frames
    repeat(4) { attempt ->                                  // retry up to 4x
        runCatching { requestFocus(); true }.getOrDefault(false).let {
            if (it) return
            if (attempt < 3) withFrameNanos { }
        }
    }
}
```

### Pattern 2: `runCatching` (for callbacks)

```kotlin
// In onFocusChanged, onKeyEvent, or onClick callbacks where suspend is unavailable
onFocusChanged { state ->
    if (state.hasFocus) {
        runCatching { requester.requestFocus() }  // catches IllegalStateException
    }
}

onPreviewKeyEvent { event ->
    if (event.key == Key.DirectionLeft) {
        runCatching { otherRequester.requestFocus() }
        true
    } else false
}
```

### Pattern 3: `FocusRequester.Cancel` for missing targets

When a `focusProperties` target might not be composed yet, use `FocusRequester.Cancel` instead of a raw requester reference:

```kotlin
focusProperties {
    right = if (targetIsAlwaysComposed) entryRequester else FocusRequester.Cancel
}
```

**But** `FocusRequester.Cancel` means the focus search stops in that direction — the user can't navigate there until the target is composed. The real fix is ensuring `contentFocusRequester` always targets the always-composed content Box in `TvAppScaffold`.

## Files Modified in This Session

| File | Change |
|---|---|
| `SettingsScreen.kt` | Bare `requestFocus()` → `requestFocusAfterFrames()` in 2 LaunchedEffects |
| `PlaceholderScreen.kt` | Bare `requestFocus()` → `requestFocusAfterFrames()` |
| `SettingsScreen.kt` | `runCatching { it.requestFocus() }` → `it.requestFocusAfterFrames()` for content panel |

## Remaining Hotspots (Not Fixed)

~15 bare `requestFocus()` calls in player screens that could still trigger the error:
- `NuvioSourceSidePanel.kt` (lines 88-93)
- `NuvioSelectionPanels.kt` (lines 84-87, 154-155, 246-248)
- `SkipIntroButton.kt` (lines 122, 155, 163)
- `ResolverOverlay.kt` (line 196)
