Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fuzzy-tvs-focus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'react-native-bottom-tabs': patch
---

Restore the previously focused content after closing an adaptable sidebar on tvOS.
18 changes: 16 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ jobs:
- name: Typecheck files
run: yarn typecheck

- name: Validate issue 501 repro
run: |
yarn tsc --project evidence/issue-501/repro/tsconfig.json
yarn eslint evidence/issue-501/repro/App.tsx

swift-lint:
runs-on: ubuntu-latest
steps:
Expand Down Expand Up @@ -108,7 +113,6 @@ jobs:
run: |
yarn turbo run build:android --cache-dir="${{ env.TURBO_CACHE_DIR }}"


build-ios:
runs-on: macos-15
env:
Expand All @@ -123,6 +127,17 @@ jobs:
- name: Build package
run: yarn build

- name: Typecheck tvOS view wrapper
run: |
mkdir -p "$RUNNER_TEMP/swift-module-cache"
xcrun --sdk appletvsimulator swiftc \
-module-cache-path "$RUNNER_TEMP/swift-module-cache" \
-target arm64-apple-tvos15.1-simulator \
-sdk "$(xcrun --sdk appletvsimulator --show-sdk-path)" \
-typecheck \
packages/react-native-bottom-tabs/ios/PlatformAliases.swift \
packages/react-native-bottom-tabs/ios/RepresentableView.swift

- name: Cache turborepo for iOS
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
Expand Down Expand Up @@ -152,4 +167,3 @@ jobs:
- name: Build example for iOS
run: |
yarn turbo run build:ios --cache-dir="${{ env.TURBO_CACHE_DIR }}"

54 changes: 54 additions & 0 deletions evidence/issue-501/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Issue #501 tvOS focus restoration evidence

## Environment

- macOS 26.6.2
- Xcode 26.6 (17F113), selected explicitly with `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer`
- Apple TV simulator, tvOS 26.5
- `agent-device` 0.20.10
- React 19.2.3
- `react-native-tvos` 0.83.1-0
- Hermes and the New Architecture enabled
- `react-native-bottom-tabs` and `@bottom-tabs/react-navigation` 1.4.0 built from this worktree
- `react-native-screens` 4.18.0 and `react-native-safe-area-context` 5.6.1

The standalone app was generated with:

```sh
npx --yes @react-native-community/cli@20.0.2 init TVFocusRepro --template @react-native-tvos/template-tv@0.83.0-0 --skip-install
```

Its screen implementation is preserved in [`repro/App.tsx`](repro/App.tsx). The Podfile used `platform :tvos`, and `RCT_NEW_ARCH_ENABLED=1 pod install` generated the native project. A test-harness-only post-install setting selected C++17 for the `fmt` pod because that version of the template otherwise fails to compile under Xcode 26.6; the library change does not alter C++ settings.

## Exact reproduction

1. Launch the app with the `Watching` route selected, `sidebarAdaptable` enabled, and `freezeOnBlur: true`.
2. Move Right from the selected sidebar tab into the list.
3. Move Down eight times so `Button 8` is focused.
4. Move Left to focus `Watching` in the expanded sidebar.
5. Move Right to close the sidebar and return to the list.

Before the fix, the semantic snapshots reported `Button 8` before opening the sidebar and `Button 0` after closing it. This matches the issue recording. See [`issue-501-baseline.mp4`](issue-501-baseline.mp4), [`baseline-focus.txt`](baseline-focus.txt), [`screenshots/issue-501-baseline-sidebar.png`](screenshots/issue-501-baseline-sidebar.png), and [`screenshots/issue-501-baseline-after-return.png`](screenshots/issue-501-baseline-after-return.png).

## Verification after the fix

- The same sequence returns focus to `Button 8`.
- Two additional Left/Right sidebar cycles continue to return to `Button 8`.
- After scrolling farther, a Left/Right cycle returns to `Button 15`, retaining the scroll position.
- A fresh install preserves the existing initial behavior: focus begins on the selected `Watching` sidebar tab, and Right enters the list at `Button 0` because no prior descendant exists.
- Switching from `Watching` to `Search` and back while `freezeOnBlur` is enabled uses the route's default `Button 0`; it does not target the stale `Button 5` from the previously active route.
- The app compiles for the repository's tvOS 15.1 deployment target with Xcode 26.6 and runs on tvOS 26.5 with Hermes and the New Architecture.
- iOS, macOS, visionOS, and Android behavior is unchanged because the focus-restoring wrapper is compiled only for tvOS.

See [`issue-501-fixed.mp4`](issue-501-fixed.mp4), [`fixed-focus.txt`](fixed-focus.txt), and [`screenshots/issue-501-fixed-button15.png`](screenshots/issue-501-fixed-button15.png).

## Repository checks

- `yarn lint` — passed with the repository's three existing warnings.
- `yarn typecheck` — passed.
- `yarn build` — passed.
- `yarn build:ios` — passed.
- `yarn build:android` — passed, including the New Architecture native build.
- `yarn tsc --project evidence/issue-501/repro/tsconfig.json` — passed.
- Standalone tvOS Xcode build — passed.
- `yarn test` — the repository command exits 1 before running tests because `@bottom-tabs/react-navigation` contains no tests. There is no native XCTest target in the library, so the focused regression is covered by the recorded simulator flow and semantic focus snapshots.
8 changes: 8 additions & 0 deletions evidence/issue-501/baseline-focus.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Before opening the sidebar:
@e13 [cell] "Button 8" [focused]

After moving Left into the sidebar:
@e17 [button] "Watching" [focused]

After moving Right back to the content:
@e5 [cell] "Button 0" [focused]
14 changes: 14 additions & 0 deletions evidence/issue-501/fixed-focus.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
After moving Down eight times:
@e13 [cell] "Button 8" [focused]

After moving Left into the sidebar and Right back to the content:
@e13 [cell] "Button 8" [focused]

After two additional sidebar round trips:
@e13 [cell] "Button 8" [focused]

After scrolling farther and completing another sidebar round trip:
@e15 [cell] "Button 15" [focused]

After a fresh install, switching from Watching to Search, and switching back:
@e5 [cell] "Button 0" [focused]
Binary file added evidence/issue-501/issue-501-baseline.mp4
Binary file not shown.
Binary file added evidence/issue-501/issue-501-fixed.mp4
Binary file not shown.
36 changes: 36 additions & 0 deletions evidence/issue-501/remediation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Issue #501 remediation

## Finding dispositions

1. **Resolved — recycled Fabric view identity.** `FocusRestoringView` now records the focused view's React tag and requires the current tag to match before returning that view from `preferredFocusEnvironments`. A recycled `UIView` whose tag has changed can no longer redirect focus to a different React element.

2. **Resolved — tvOS compile coverage.** The macOS CI job now invokes `swiftc` for an `arm64-apple-tvos15.1-simulator` target over `PlatformAliases.swift` and `RepresentableView.swift`. This directly compiles the `#if os(tvOS)` implementation at the library's declared tvOS deployment target without adding another example application or changing dependency versions.

3. **Resolved — release metadata.** `.changeset/fuzzy-tvs-focus.md` adds a patch changeset for `react-native-bottom-tabs`. The repository's fixed/dependent package rules propagate the release as shown by `yarn changeset status`.

4. **Resolved within the required evidence scope — evidence size, duplication, and repro verification.** The issue-specific directory is retained because the remediation requires the committed before/after recordings, useful screenshots, repro source, semantic snapshots, review JSON, and exact notes. The byte-identical `screenshots/issue-501-baseline-initial.png` was removed; its SHA-256 was the same as the retained and accurately named `screenshots/issue-501-baseline-after-return.png` (`054a9995b635dcc46e3d106640fb5f2e6505f7a16d673bf3f9a31d421276a913`). The reporter's `tvParallaxProperties` setting remains in the repro through a narrowly typed local wrapper because the repository typechecks against core React Native rather than the extended `react-native-tvos` declarations. `repro/tsconfig.json` makes the complete source independently typecheckable, and CI now typechecks and lints it. Both HEVC recordings, the three distinct screenshots, both semantic snapshots, the repro, the review, and the reproduction notes remain under `evidence/issue-501/`.

5. **Resolved — Swift brace style.** The multiline `if let` opening brace is now on the final condition line, matching the configured SwiftLint `opening_brace` rule.

6. **Unchanged — inapplicable to supported architectures.** The reported failure requires React Native's Paper architecture. This repository removed old-architecture support in commit `4b4e781cead514784c46599ab09554fad6c41208` (`feat!: drop old architecture`), recorded in `packages/react-native-bottom-tabs/CHANGELOG.md`. That commit deleted `RCTTabViewViewManager.mm`, removed the podspec's architecture conditional, and made New Architecture module installation unconditional. The current `RCTTabViewComponentView.mm` mounts and unmounts Fabric children through `insertChild` and `removeChildAtIndex`, which preserve the identities of unaffected children; it does not use `didUpdateReactSubviews`. Changing `IdentifiablePlatformView.id` for an unsupported Paper-only callback would therefore be a broader, unverified behavior change rather than a fix for a supported configuration.

## Validation performed

- `yarn lint` — passed with the three pre-existing warnings in `TabView.tsx` and `TabViewNativeComponent.ts` and no errors.
- `yarn typecheck` — passed.
- `yarn build` — passed.
- `yarn build:ios` — passed.
- `yarn build:android` — passed, including the New Architecture native build.
- `yarn tsc --project evidence/issue-501/repro/tsconfig.json` — passed.
- `yarn eslint evidence/issue-501/repro/App.tsx` — passed.
- `yarn prettier --check .github/workflows/ci.yml .changeset/fuzzy-tvs-focus.md evidence/issue-501/README.md evidence/issue-501/repro/App.tsx evidence/issue-501/repro/tsconfig.json` — passed.
- `ruby -e 'require "yaml"; YAML.load_file(".github/workflows/ci.yml")'` — passed.
- The standalone Hermes/Fabric repro rebuilt successfully with Xcode 26.6 (17F113), the tvOS 26.5 SDK, and a tvOS 15.1 deployment target after installing the final packed library.
- `xcrun --sdk appletvsimulator swiftc -module-cache-path /tmp/issue-501-swift-module-cache -target arm64-apple-tvos15.1-simulator -sdk "$(xcrun --sdk appletvsimulator --show-sdk-path)" -typecheck packages/react-native-bottom-tabs/ios/PlatformAliases.swift packages/react-native-bottom-tabs/ios/RepresentableView.swift` — passed with Xcode 26.6 and the tvOS 26.5 SDK while targeting tvOS 15.1.
- Equivalent `swiftc -typecheck` invocations passed for iOS 14.0 Simulator, macOS 11.0, and visionOS 1.0 Simulator targets.
- `yarn changeset status` — passed and reported patch bumps for `react-native-bottom-tabs` plus packages propagated by the repository's release configuration.
- `jq -e '.findings | length == 6 and all(.[]; has("file") and has("line") and has("defect") and has("failure_scenario") and has("severity"))' evidence/issue-501/review.json` — returned `true`.
- `git diff --check` — passed.
- `ffprobe` confirmed both retained videos are readable 1920×1080 HEVC recordings: baseline duration 19.67 seconds and final fixed duration 61.83 seconds. Contact-sheet inspection confirmed the baseline failure and final fixed focus sequences. `shasum -a 256` confirmed the removed screenshot was the sole duplicate among the screenshots.

SwiftLint is not installed in this sandbox, so its executable was not run locally. The post-remediation `agent-device` replay on tvOS 26.5 verified two `Button 8` sidebar round trips, the route-switch default of `Button 0`, and scrolled restoration to `Button 15`; it replaced the retained fixed recording and screenshot with evidence from the final code.
150 changes: 150 additions & 0 deletions evidence/issue-501/repro/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import * as React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';

const Tabs = createNativeBottomTabNavigator();
const TVPressable = Pressable as React.ComponentType<
React.ComponentProps<typeof Pressable> & {
tvParallaxProperties?: { magnification?: number };
}
>;

function WatchingScreen() {
const [focusedIndex, setFocusedIndex] = React.useState<number | null>(null);

return (
<View style={styles.screen}>
<Text accessibilityLabel="Focus status" style={styles.status}>
Focused: {focusedIndex === null ? 'none' : `Button ${focusedIndex}`}
</Text>
<ScrollView contentContainerStyle={styles.content}>
{Array.from({ length: 20 }, (_, index) => (
<TVPressable
accessibilityLabel={`Button ${index}`}
key={index}
onFocus={() => setFocusedIndex(index)}
style={[
styles.button,
focusedIndex === index && styles.focusedButton,
]}
testID={`button-${index}`}
tvParallaxProperties={{ magnification: 1.1 }}
>
<Text
style={[
styles.buttonText,
focusedIndex === index && styles.focusedButtonText,
]}
>
Button {index}
</Text>
</TVPressable>
))}
</ScrollView>
</View>
);
}

function SearchScreen() {
return (
<View style={styles.center}>
<Pressable accessibilityLabel="Search action" style={styles.button}>
<Text style={styles.buttonText}>Search action</Text>
</Pressable>
</View>
);
}

function NewScreen() {
return (
<View style={styles.center}>
<Pressable accessibilityLabel="New action" style={styles.button}>
<Text style={styles.buttonText}>New action</Text>
</Pressable>
</View>
);
}

function App() {
return (
<NavigationContainer>
<Tabs.Navigator
initialRouteName="Watching"
sidebarAdaptable
screenOptions={{ freezeOnBlur: true }}
>
<Tabs.Screen
component={WatchingScreen}
name="Watching"
options={{
title: 'Watching',
tabBarIcon: () => ({ sfSymbol: 'star.fill' }),
}}
/>
<Tabs.Screen
component={SearchScreen}
name="Search"
options={{
title: 'Search',
tabBarIcon: () => ({ sfSymbol: 'magnifyingglass' }),
}}
/>
<Tabs.Screen
component={NewScreen}
name="New"
options={{
title: 'New',
tabBarIcon: () => ({ sfSymbol: 'plus' }),
}}
/>
</Tabs.Navigator>
</NavigationContainer>
);
}

const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: '#101114',
},
status: {
color: 'white',
fontSize: 24,
marginHorizontal: 80,
marginTop: 30,
},
content: {
gap: 24,
padding: 80,
},
button: {
alignItems: 'center',
backgroundColor: '#30343b',
borderRadius: 10,
minHeight: 64,
justifyContent: 'center',
paddingHorizontal: 30,
},
focusedButton: {
backgroundColor: '#f4f4f4',
transform: [{ scale: 1.04 }],
},
buttonText: {
color: '#f4f4f4',
fontSize: 22,
fontWeight: '600',
},
focusedButtonText: {
color: '#111217',
},
center: {
alignItems: 'center',
backgroundColor: '#101114',
flex: 1,
justifyContent: 'center',
padding: 80,
},
});

export default App;
8 changes: 8 additions & 0 deletions evidence/issue-501/repro/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"composite": false,
"noEmit": true
},
"include": ["App.tsx"]
}
Loading
Loading