From 252c58f1203358fb2c99185fd0b551c8dd5ffa80 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 21:21:22 +0200 Subject: [PATCH] fix(tv): lazy-load @expo/ui to prevent tvOS crash at module load --- components/PlatformDropdown.tsx | 15 ++++++++++++--- components/search/DiscoverFilters.tsx | 14 +++++++++++--- components/search/SearchTabButtons.tsx | 14 +++++++++++--- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/components/PlatformDropdown.tsx b/components/PlatformDropdown.tsx index 8f81e567e..aaea71b3f 100644 --- a/components/PlatformDropdown.tsx +++ b/components/PlatformDropdown.tsx @@ -1,5 +1,3 @@ -import { Button, Host, Menu } from "@expo/ui/swift-ui"; -import { disabled } from "@expo/ui/swift-ui/modifiers"; import { Ionicons } from "@expo/vector-icons"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import React, { useEffect, useState } from "react"; @@ -14,6 +12,17 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { useGlobalModal } from "@/providers/GlobalModalProvider"; +// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds. +// A static top-level import evaluates requireNativeModule('ExpoUI') at module +// load and crashes the entire route tree on tvOS (expo-router requires every +// route file). Load it lazily and only off-TV; TV never renders these. +const { Button, Host, Menu } = Platform.isTV + ? ({} as typeof import("@expo/ui/swift-ui")) + : require("@expo/ui/swift-ui"); +const { disabled } = Platform.isTV + ? ({} as typeof import("@expo/ui/swift-ui/modifiers")) + : require("@expo/ui/swift-ui/modifiers"); + // Option types export type RadioOption = { type: "radio"; @@ -255,7 +264,7 @@ const PlatformDropdownComponent = ({ } }, [isVisible, controlledOpen, controlledOnOpenChange]); - if (Platform.OS === "ios") { + if (Platform.OS === "ios" && !Platform.isTV) { // Pin the wrapper to the measured trigger size. @expo/ui's (SDK 55) // fills its parent and reports its own size via setStyleSize, so it can't // size itself to content. If the wrapper has no size, the Host's `flex: 1` diff --git a/components/search/DiscoverFilters.tsx b/components/search/DiscoverFilters.tsx index 59fd51c94..3f70c968d 100644 --- a/components/search/DiscoverFilters.tsx +++ b/components/search/DiscoverFilters.tsx @@ -1,9 +1,17 @@ -import { Button, Host, Menu } from "@expo/ui/swift-ui"; -import { buttonStyle } from "@expo/ui/swift-ui/modifiers"; import { Platform, View } from "react-native"; import { FilterButton } from "@/components/filters/FilterButton"; import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage"; +// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds. +// A static top-level import crashes the route tree on tvOS at module load. +// Load it lazily and only off-TV; TV never renders this component. +const { Button, Host, Menu } = Platform.isTV + ? ({} as typeof import("@expo/ui/swift-ui")) + : require("@expo/ui/swift-ui"); +const { buttonStyle } = Platform.isTV + ? ({} as typeof import("@expo/ui/swift-ui/modifiers")) + : require("@expo/ui/swift-ui/modifiers"); + interface DiscoverFiltersProps { searchFilterId: string; orderFilterId: string; @@ -29,7 +37,7 @@ export const DiscoverFilters: React.FC = ({ setJellyseerrSortOrder, t, }) => { - if (Platform.OS === "ios") { + if (Platform.OS === "ios" && !Platform.isTV) { return ( = ({ setSearchType, t, }) => { - if (Platform.OS === "ios") { + if (Platform.OS === "ios" && !Platform.isTV) { return (