diff --git a/src/plugins/start/error-boundary/components/ErrorBoundaryScreen.tsx b/src/plugins/start/error-boundary/components/ErrorBoundaryScreen.tsx
new file mode 100644
index 0000000..6ad117b
--- /dev/null
+++ b/src/plugins/start/error-boundary/components/ErrorBoundaryScreen.tsx
@@ -0,0 +1,244 @@
+import { styles } from '@revenge-mod/components/_'
+import { Tokens } from '@revenge-mod/discord/common'
+import { Design } from '@revenge-mod/discord/design'
+import { ClientInfoModule } from '@revenge-mod/discord/native'
+import { Clipboard } from '@revenge-mod/externals/react-native-clipboard'
+import { getErrorStack } from '@revenge-mod/utils/error'
+import { SafeAreaView, ScrollView, StyleSheet, View } from 'react-native'
+import { FullVersion } from '~/constants'
+import type { ComponentProps } from 'react'
+
+const { createStyles, Button, Card, Stack, Text } = Design
+
+const useErrorBoundaryStyles = createStyles({
+ view: {
+ backgroundColor: Tokens.default.colors.BG_BASE_SECONDARY,
+ paddingHorizontal: 16,
+ paddingVertical: 24,
+ flex: 1,
+ gap: 16,
+ },
+})
+
+const additionalStyles = StyleSheet.create({
+ headerContainer: {
+ gap: 4,
+ },
+ scrollView: {
+ gap: 8,
+ flex: 1,
+ },
+ stackTrace: {
+ fontFamily: 'monospace',
+ fontWeight: 'bold',
+ marginBottom: 8,
+ },
+})
+
+interface RenderError extends Error {
+ componentStack?: string
+}
+
+export default function ErrorBoundaryScreen({
+ error,
+ rerender,
+ reload,
+}: {
+ error: RenderError | unknown
+ rerender: () => void
+ reload: () => void
+}) {
+ const errorBoundaryStyles = useErrorBoundaryStyles()
+ const Client = ClientInfoModule.getConstants()
+
+ return (
+
+
+ Error!
+
+ An error was thrown while rendering components. This could
+ be caused by plugins, Revenge or Discord.
+
+
+ {Client.Version} ({Client.Build}) • {FullVersion}
+
+
+
+ {String(error)}
+ {error instanceof Error && error.stack && (
+
+ {parseStackTrace(
+ error.stack?.slice(String(error).length + 1),
+ ).map(({ at, file, line, column }) => (
+ // biome-ignore lint/correctness/useJsxKeyInIterable: This never gets rerendered
+
+ {at}
+ {'\n'}
+
+ {file}
+ {
+ <>
+ :{line}:{column}
+ >
+ }
+
+
+ ))}
+
+ )}
+
+ {error instanceof Error &&
+ (error as RenderError).componentStack && (
+
+
+ {...parseStackTrace(
+ (error as RenderError).componentStack!.slice(1),
+ ).map(({ at }) => [
+ '<',
+ // biome-ignore lint/correctness/useJsxKeyInIterable: This never gets rerendered
+ {at},
+ '/>\n',
+ ])}
+
+
+ )}
+
+
+
+
+
+ )
+}
+
+export interface LabeledCardProps extends ComponentProps {
+ label: string
+ rawContent?: string
+ scrollable?: boolean
+}
+
+export function LabeledCard(props: LabeledCardProps) {
+ const ViewComponent = props.scrollable ? ScrollView : View
+
+ return (
+
+
+
+ {props.label}
+
+ {props.rawContent && (
+
+
+ {props.children}
+
+
+ )
+}
+
+interface StackFrame {
+ at: string
+ file: string
+ line: number | null
+ column: number | null
+}
+
+const IndexBundleFilePath = (
+ HermesInternal as HermesInternalObject
+).getFunctionLocation(alert).fileName
+const RevengeFilePath = (
+ HermesInternal as HermesInternalObject
+).getFunctionLocation(ErrorBoundaryScreen).fileName
+
+const StackFrameRegex = /at (.+) \(([^:]+):(\d+):(\d+)\)|at (.+)? \(([^)]+)\)/
+
+function parseStackTrace(stackTrace: string): StackFrame[] {
+ const frames: StackFrame[] = []
+ const lines = stackTrace.split('\n')
+
+ for (const line of lines) {
+ const match = StackFrameRegex.exec(line.trim())
+
+ if (match) {
+ let at: string
+ let path: string
+ let ln: number | null = null
+ let col: number | null = null
+
+ if (match[3] && match[4]) {
+ // Case where line and column numbers are present
+ at = match[1]!
+ path = match[2]!
+ ln = Number(match[3])
+ col = Number(match[4])
+ } else {
+ // Case where line and column numbers are missing (native functions or just file path)
+ at = match[5]!
+ path = match[6]!
+ }
+
+ switch (path) {
+ case IndexBundleFilePath:
+ path = 'Discord'
+ break
+ case RevengeFilePath:
+ path = 'Revenge'
+ break
+ }
+
+ frames.push({
+ at: at,
+ file: path,
+ line: ln,
+ column: col,
+ })
+ } else {
+ // For lines that don't match the expected format
+ frames.push({
+ at: 'UNABLE TO PARSE LINE',
+ file: line,
+ line: null,
+ column: null,
+ })
+ }
+ }
+
+ return frames
+}
diff --git a/src/plugins/start/error-boundary/index.tsx b/src/plugins/start/error-boundary/index.tsx
new file mode 100644
index 0000000..6ca0d8a
--- /dev/null
+++ b/src/plugins/start/error-boundary/index.tsx
@@ -0,0 +1,63 @@
+import { byName } from '@revenge-mod/modules/finders/filters'
+import { waitForModules } from '@revenge-mod/modules/finders/wait'
+import { instead } from '@revenge-mod/patcher'
+import { InternalPluginFlags, registerPlugin } from '@revenge-mod/plugins/_'
+import { PluginFlags } from '@revenge-mod/plugins/constants'
+import ErrorBoundaryScreen from './components/ErrorBoundaryScreen'
+import type { Component, ReactNode } from 'react'
+
+registerPlugin(
+ {
+ id: 'revenge.error-boundary',
+ name: 'Error Boundary',
+ description: "Wraps Discord's Error Boundary to show more details.",
+ author: 'Revenge',
+ icon: 'CircleXIcon',
+ },
+ {
+ start({ cleanup }) {
+ const unsubEB = waitForModules(byName('ErrorBoundary'), exports => {
+ unsubEB()
+
+ instead(
+ (exports as typeof DiscordErrorBoundary).prototype,
+ 'render',
+ function (this: DiscordErrorBoundary) {
+ if (this.state.error)
+ return (
+ {
+ this.setState({
+ error: null,
+ info: null,
+ })
+ }}
+ />
+ )
+
+ return this.props.children
+ },
+ )
+ })
+
+ cleanup(unsubEB)
+ },
+ },
+ PluginFlags.Enabled,
+ InternalPluginFlags.Internal | InternalPluginFlags.Essential,
+)
+
+declare class DiscordErrorBoundary extends Component<
+ { children: ReactNode },
+ {
+ error: (Error & { componentStack?: string }) | unknown | null
+ info: { componentStack?: string } | null
+ }
+> {
+ // render() is always called with `this` as the instance of DiscordErrorBoundary
+ render(this: DiscordErrorBoundary): ReactNode
+ discordErrorsSet: boolean
+ handleReload(): void
+}