fix(web): 补全缺失的 web shims 并修复渲染崩溃问题

- 新增 sentry-react-native、react-native-video、react-native-static-server、expo-network、expo-intent-launcher、expo-clipboard、expo-file-system 的 web shim
- 重写 react-native-pager-view shim,通过 useImperativeHandle 暴露 setPage 方法
- 修复 sentry-react-native shim 缺少 ErrorBoundary 导致的渲染崩溃
- 在 _layout.tsx 中加载 Ionicons 字体,修复 web 端字体 404
- 更新 metro.config.js 中 sentry shim 路径为 .tsx
This commit is contained in:
Action
2026-03-24 21:21:15 +08:00
parent ce82f1be71
commit 6f7c99906b
10 changed files with 168 additions and 14 deletions

View File

@@ -9,6 +9,8 @@ import { useSettingsStore } from '../store/settingsStore';
import { MiniPlayer } from '../components/MiniPlayer';
import * as Sentry from '@sentry/react-native';
import { ErrorBoundary } from '@sentry/react-native';
import { useFonts } from 'expo-font';
import { Ionicons } from '@expo/vector-icons';
Sentry.init({
dsn: process.env.EXPO_PUBLIC_SENTRY_DSN ?? '',
@@ -22,13 +24,18 @@ function RootLayout() {
const loadDownloads = useDownloadStore(s => s.loadFromStorage);
const restoreSettings = useSettingsStore(s => s.restore);
const [fontsLoaded] = useFonts({
...Ionicons.font,
});
useEffect(() => {
restore();
loadDownloads();
restoreSettings();
}, []);
if (!fontsLoaded) return null;
return (
<SafeAreaProvider>
<StatusBar style="dark" />

View File

@@ -1,16 +1,29 @@
const path = require('path');
const {
getSentryExpoConfig
} = require("@sentry/react-native/metro");
const { getSentryExpoConfig } = require("@sentry/react-native/metro");
const config = getSentryExpoConfig(__dirname);
// Ensure shims directory is watched by Metro
config.watchFolders = [...(config.watchFolders ?? []), path.resolve(__dirname, 'shims')];
const originalResolveRequest = config.resolver.resolveRequest;
const WEB_SHIMS = {
'react-native-pager-view': 'shims/react-native-pager-view.web.tsx',
'@sentry/react-native': 'shims/sentry-react-native.web.tsx',
'@dr.pogodin/react-native-static-server': 'shims/react-native-static-server.web.ts',
'expo-network': 'shims/expo-network.web.ts',
'expo-intent-launcher': 'shims/expo-intent-launcher.web.ts',
'react-native-video': 'shims/react-native-video.web.tsx',
'expo-file-system': 'shims/expo-file-system.web.ts',
'expo-file-system/legacy': 'shims/expo-file-system.web.ts',
'expo-clipboard': 'shims/expo-clipboard.web.ts',
};
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (platform === 'web' && moduleName === 'react-native-pager-view') {
if (platform === 'web' && WEB_SHIMS[moduleName]) {
return {
filePath: path.resolve(__dirname, 'shims/react-native-pager-view.web.tsx'),
filePath: path.resolve(__dirname, WEB_SHIMS[moduleName]),
type: 'sourceFile',
};
}

View File

@@ -0,0 +1,7 @@
/** Web shim for expo-clipboard - use native browser clipboard API */
export async function setStringAsync(text: string): Promise<void> {
try { await navigator.clipboard.writeText(text); } catch {}
}
export async function getStringAsync(): Promise<string> {
try { return await navigator.clipboard.readText(); } catch { return ''; }
}

View File

@@ -0,0 +1,14 @@
/** Web shim for expo-file-system */
export const documentDirectory = '';
export const cacheDirectory = '';
export async function getInfoAsync(_uri: string) { return { exists: false, isDirectory: false }; }
export async function readAsStringAsync(_uri: string) { return ''; }
export async function writeAsStringAsync(_uri: string, _contents: string) {}
export async function deleteAsync(_uri: string) {}
export async function moveAsync(_opts: any) {}
export async function copyAsync(_opts: any) {}
export async function makeDirectoryAsync(_uri: string) {}
export async function getContentUriAsync(_uri: string) { return ''; }
export function createDownloadResumable(_url: string, _fileUri: string, _opts?: any, _cb?: any) {
return { downloadAsync: async () => ({}) };
}

View File

@@ -0,0 +1,5 @@
/** Web shim for expo-intent-launcher - no-op for web */
export async function startActivityAsync(_activity: string, _params?: unknown): Promise<unknown> {
return {};
}
export const ActivityAction = {};

10
shims/expo-network.web.ts Normal file
View File

@@ -0,0 +1,10 @@
/** Web shim for expo-network */
export enum NetworkStateType {
NONE = 0, UNKNOWN = 1, CELLULAR = 2, WIFI = 3, BLUETOOTH = 4,
ETHERNET = 5, WIMAX = 6, VPN = 7, OTHER = 8,
}
export async function getNetworkStateAsync() {
return { isConnected: navigator.onLine, isInternetReachable: navigator.onLine, type: NetworkStateType.UNKNOWN };
}
export async function getIpAddressAsync(): Promise<string> { return '0.0.0.0'; }
export async function isAirplaneModeEnabledAsync(): Promise<boolean> { return false; }

View File

@@ -1,7 +1,6 @@
/**
* Web shim for react-native-pager-view.
* eas update exports for web; this replaces the native-only module
* with a simple View-based container that renders the first child only.
* Supports setPage/setPageWithoutAnimation via imperative handle.
*/
import React from 'react';
import { View, type ViewStyle } from 'react-native';
@@ -11,16 +10,35 @@ interface PagerViewProps {
style?: ViewStyle;
initialPage?: number;
scrollEnabled?: boolean;
onPageSelected?: (e: any) => void;
onPageSelected?: (e: { nativeEvent: { position: number } }) => void;
onPageScrollStateChanged?: (e: any) => void;
[key: string]: any;
}
const PagerView = React.forwardRef<View, PagerViewProps>(
({ children, style, initialPage = 0 }, ref) => {
export interface PagerViewHandle {
setPage: (page: number) => void;
setPageWithoutAnimation: (page: number) => void;
}
const PagerView = React.forwardRef<PagerViewHandle, PagerViewProps>(
({ children, style, initialPage = 0, onPageSelected }, ref) => {
const [currentPage, setCurrentPage] = React.useState(initialPage);
const pages = React.Children.toArray(children);
React.useImperativeHandle(ref, () => ({
setPage(page: number) {
setCurrentPage(page);
onPageSelected?.({ nativeEvent: { position: page } });
},
setPageWithoutAnimation(page: number) {
setCurrentPage(page);
onPageSelected?.({ nativeEvent: { position: page } });
},
}));
return (
<View ref={ref} style={[{ flex: 1 }, style]}>
{pages[initialPage] ?? pages[0] ?? null}
<View style={[{ flex: 1 }, style]}>
{pages[currentPage] ?? pages[0] ?? null}
</View>
);
},

View File

@@ -0,0 +1,8 @@
/** Web shim for @dr.pogodin/react-native-static-server - no-op for web */
export default class StaticServer {
constructor(_port?: number, _root?: string, _options?: unknown) {}
start(): Promise<string> { return Promise.resolve(''); }
stop(): Promise<void> { return Promise.resolve(); }
isRunning(): boolean { return false; }
get origin(): string { return ''; }
}

View File

@@ -0,0 +1,42 @@
/** Web shim for react-native-video - uses HTML5 <video> element */
import React from 'react';
interface VideoProps {
source?: { uri?: string } | number;
style?: React.CSSProperties;
paused?: boolean;
muted?: boolean;
repeat?: boolean;
onLoad?: (data: unknown) => void;
onError?: (error: unknown) => void;
onProgress?: (data: unknown) => void;
onEnd?: () => void;
resizeMode?: string;
[key: string]: unknown;
}
const Video = React.forwardRef<HTMLVideoElement, VideoProps>(
({ source, style, paused, muted, repeat, onLoad, onError, onProgress, onEnd }, ref) => {
const uri = typeof source === 'object' && source !== null ? (source as { uri?: string }).uri : undefined;
return (
<video
ref={ref}
src={uri}
style={style as React.CSSProperties}
autoPlay={!paused}
muted={muted}
loop={repeat}
onLoadedData={onLoad ? () => onLoad({}) : undefined}
onError={onError ? (e) => onError(e) : undefined}
onTimeUpdate={onProgress ? (e) => {
const t = e.currentTarget;
onProgress({ currentTime: t.currentTime, seekableDuration: t.duration });
} : undefined}
onEnded={onEnd}
playsInline
/>
);
}
);
export default Video;

View File

@@ -0,0 +1,30 @@
/** Web shim for @sentry/react-native - no-op stubs for web platform */
import React from 'react';
export const init = (_options?: unknown) => {};
export const wrap = <T>(component: T): T => component;
export const captureException = (_error: unknown, _hint?: unknown) => '';
export const captureMessage = (_message: string, _level?: unknown) => '';
export const setUser = (_user: unknown) => {};
export const setTag = (_key: string, _value: unknown) => {};
export const setExtra = (_key: string, _extra: unknown) => {};
export const addBreadcrumb = (_breadcrumb: unknown) => {};
export const configureScope = (_callback: unknown) => {};
export const withScope = (_callback: unknown) => {};
export const getCurrentHub = () => ({ getClient: () => undefined });
export const ReactNativeTracing = class {};
export const ReactNavigationInstrumentation = class {};
export const TouchEventBoundary = ({ children }: { children: React.ReactNode }) => children;
export class ErrorBoundary extends React.Component<
{ fallback: React.ReactNode; children: React.ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
render() {
return this.state.hasError ? this.props.fallback : this.props.children;
}
}
export default { init, wrap, captureException, captureMessage, setUser, setTag, setExtra };