feat: port ErrorBoundary from dev
This commit is contained in:
parent
a20735466d
commit
f44791049a
13 changed files with 475 additions and 185 deletions
21
src/core/debug/patches/patchErrorBoundary.tsx
Normal file
21
src/core/debug/patches/patchErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import ErrorBoundaryScreen from "@core/ui/reporter/components/ErrorBoundaryScreen";
|
||||
import { after } from "@lib/api/patcher";
|
||||
import { _lazyContextSymbol } from "@metro/lazy";
|
||||
import { LazyModuleContext } from "@metro/types";
|
||||
import { findByNameLazy } from "@metro/wrappers";
|
||||
|
||||
function getErrorBoundaryContext() {
|
||||
const ctxt: LazyModuleContext = findByNameLazy("ErrorBoundary")[_lazyContextSymbol];
|
||||
return new Promise(resolve => ctxt.getExports(exp => resolve(exp.prototype)));
|
||||
}
|
||||
|
||||
export default function patchErrorBoundary() {
|
||||
return after.await("render", getErrorBoundaryContext(), function (this: any) {
|
||||
if (!this.state.error) return;
|
||||
|
||||
return <ErrorBoundaryScreen
|
||||
error={this.state.error}
|
||||
rerender={() => this.setState({ info: null, error: null })}
|
||||
/>;
|
||||
});
|
||||
}
|
||||
52
src/core/ui/reporter/components/ErrorBoundaryScreen.tsx
Normal file
52
src/core/ui/reporter/components/ErrorBoundaryScreen.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { hasStack, isComponentStack } from "@core/ui/reporter/utils/isStack";
|
||||
import { getDebugInfo, toggleSafeMode } from "@lib/api/debug";
|
||||
import { BundleUpdaterManager } from "@lib/api/native/modules";
|
||||
import { settings } from "@lib/api/settings";
|
||||
import { Codeblock, ErrorBoundary } from "@lib/ui/components";
|
||||
import { createStyles } from "@lib/ui/styles";
|
||||
import { tokens } from "@metro/common";
|
||||
import { Button, Card, SafeAreaView, Text } from "@metro/common/components";
|
||||
import { ScrollView, View } from "react-native";
|
||||
|
||||
import ErrorComponentStackCard from "./ErrorComponentStackCard";
|
||||
import ErrorStackCard from "./ErrorStackCard";
|
||||
|
||||
const useStyles = createStyles({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: tokens.colors.BACKGROUND_PRIMARY,
|
||||
paddingHorizontal: 16,
|
||||
height: "100%",
|
||||
padding: 8,
|
||||
gap: 12
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export default function ErrorBoundaryScreen(props: {
|
||||
error: Error;
|
||||
rerender: () => void;
|
||||
}) {
|
||||
const styles = useStyles();
|
||||
const debugInfo = getDebugInfo();
|
||||
|
||||
return <ErrorBoundary>
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={{ gap: 4 }}>
|
||||
<Text variant="display-lg">Uh oh.</Text>
|
||||
<Text variant="text-md/normal">A crash occured while rendering a component. This could be caused by a plugin, Bunny or Discord itself.</Text>
|
||||
<Text variant="text-sm/normal" color="text-muted">{debugInfo.os.name}; {debugInfo.discord.build} ({debugInfo.discord.version}); {debugInfo.bunny.version}</Text>
|
||||
</View>
|
||||
<ScrollView fadingEdgeLength={64} contentContainerStyle={{ gap: 12 }}>
|
||||
<Codeblock selectable={true}>{props.error.message}</Codeblock>
|
||||
{hasStack(props.error) && <ErrorStackCard error={props.error} />}
|
||||
{isComponentStack(props.error) ? <ErrorComponentStackCard componentStack={props.error.componentStack} /> : null}
|
||||
</ScrollView>
|
||||
<Card style={{ gap: 6 }}>
|
||||
<Button text="Reload Discord" onPress={() => BundleUpdaterManager.reload()} />
|
||||
{!settings.safeMode?.enabled && <Button text="Reload in Safe Mode" onPress={() => toggleSafeMode()} />}
|
||||
<Button variant="destructive" text="Retry Render" onPress={() => props.rerender()} />
|
||||
</Card>
|
||||
</SafeAreaView>
|
||||
</ErrorBoundary>;
|
||||
}
|
||||
44
src/core/ui/reporter/components/ErrorCard.tsx
Normal file
44
src/core/ui/reporter/components/ErrorCard.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { Strings } from "@core/i18n";
|
||||
import { Codeblock } from "@lib/ui/components";
|
||||
import { showSheet } from "@lib/ui/sheets";
|
||||
import { Button, Card, Stack, Text, TwinButtons } from "@metro/common/components";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import ErrorDetailsActionSheet from "./ErrorDetailsActionSheet";
|
||||
|
||||
export const INDEX_BUNDLE_FILE: string = window.HermesInternal.getFunctionLocation(window.__r).fileName;
|
||||
|
||||
interface ErrorCardProps {
|
||||
error: unknown;
|
||||
header?: string | ReactNode;
|
||||
onRetryRender?: () => void;
|
||||
}
|
||||
|
||||
export default function ErrorCard(props: ErrorCardProps) {
|
||||
return <Card>
|
||||
<Stack>
|
||||
{props.header && typeof props.header !== "string"
|
||||
? props.header
|
||||
: <Text variant="heading-lg/bold">{props.header ?? Strings.UH_OH}</Text>
|
||||
}
|
||||
<Codeblock selectable={true}>{String(props.error)}</Codeblock>
|
||||
<TwinButtons>
|
||||
{props.onRetryRender && <Button
|
||||
variant="destructive"
|
||||
// icon={findAssetId("RetryIcon")}
|
||||
text={Strings.RETRY_RENDER}
|
||||
onPress={props.onRetryRender}
|
||||
/>}
|
||||
{props.error instanceof Error ? <Button
|
||||
text={"Details"}
|
||||
// icon={findAssetId("CircleInformationIcon-primary")}
|
||||
onPress={() => showSheet(
|
||||
"BunnyErrorDetailsActionSheet",
|
||||
ErrorDetailsActionSheet,
|
||||
{ error: props.error as Error }
|
||||
)}
|
||||
/> : null}
|
||||
</TwinButtons>
|
||||
</Stack>
|
||||
</Card>;
|
||||
}
|
||||
53
src/core/ui/reporter/components/ErrorComponentStackCard.tsx
Normal file
53
src/core/ui/reporter/components/ErrorComponentStackCard.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { parseComponentStack } from "@core/ui/reporter/utils/parseComponentStack";
|
||||
import { findAssetId } from "@lib/api/assets";
|
||||
import { clipboard } from "@metro/common";
|
||||
import { Button, Card, Text } from "@metro/common/components";
|
||||
import { useState } from "react";
|
||||
import { Image, View } from "react-native";
|
||||
|
||||
export default function ErrorComponentStackCard(props: {
|
||||
componentStack: string;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
|
||||
let stack: string[];
|
||||
try {
|
||||
stack = parseComponentStack(props.componentStack);
|
||||
stack = collapsed ? stack.slice(0, 4) : stack;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
return <Card>
|
||||
<View style={{ gap: 8 }}>
|
||||
<Text variant="heading-lg/bold">
|
||||
Component Stack
|
||||
</Text>
|
||||
<View style={{ gap: 4 }}>
|
||||
{stack.map(component => (
|
||||
<View style={{ flexDirection: "row" }}>
|
||||
<Text variant="text-md/bold" color="text-muted">{"<"}</Text>
|
||||
<Text variant="text-md/bold">{component}</Text>
|
||||
<Text variant="text-md/bold" color="text-muted">{"/>"}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{collapsed && <Text>...</Text>}
|
||||
<View style={{ gap: 8, flexDirection: "row", justifyContent: "center", alignItems: "center" }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
text={`Show ${collapsed ? "more" : "less"}`}
|
||||
icon={collapsed ? findAssetId("down_arrow") : <Image
|
||||
style={{ transform: [{ rotate: `${collapsed ? 0 : 180}deg` }] }}
|
||||
source={findAssetId("down_arrow")!}
|
||||
/>}
|
||||
onPress={() => setCollapsed(v => !v)} />
|
||||
<Button
|
||||
variant="secondary"
|
||||
text="Copy"
|
||||
icon={findAssetId("CopyIcon")}
|
||||
onPress={() => clipboard.setString(props.componentStack)} />
|
||||
</View>
|
||||
</View>
|
||||
</Card>;
|
||||
}
|
||||
20
src/core/ui/reporter/components/ErrorDetailsActionSheet.tsx
Normal file
20
src/core/ui/reporter/components/ErrorDetailsActionSheet.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { hasStack, isComponentStack } from "@core/ui/reporter/utils/isStack";
|
||||
import { Codeblock } from "@lib/ui/components";
|
||||
import { ActionSheet, Text } from "@metro/common/components";
|
||||
import { View } from "react-native";
|
||||
|
||||
import ErrorComponentStackCard from "./ErrorComponentStackCard";
|
||||
import ErrorStackCard from "./ErrorStackCard";
|
||||
|
||||
export default function ErrorDetailsActionSheet(props: {
|
||||
error: Error;
|
||||
}) {
|
||||
return <ActionSheet>
|
||||
<View style={{ gap: 12, paddingVertical: 12 }}>
|
||||
<Text variant="heading-lg/extrabold">Error</Text>
|
||||
<Codeblock selectable={true}>{props.error.message}</Codeblock>
|
||||
{hasStack(props.error) && <ErrorStackCard error={props.error} />}
|
||||
{isComponentStack(props.error) ? <ErrorComponentStackCard componentStack={props.error.componentStack} /> : null}
|
||||
</View>
|
||||
</ActionSheet>;
|
||||
}
|
||||
63
src/core/ui/reporter/components/ErrorStackCard.tsx
Normal file
63
src/core/ui/reporter/components/ErrorStackCard.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import parseErrorStack, { StackFrame } from "@core/ui/reporter/utils/parseErrorStack";
|
||||
import { findAssetId } from "@lib/api/assets";
|
||||
import { clipboard, constants } from "@metro/common";
|
||||
import { Button, Card, Text } from "@metro/common/components";
|
||||
import { useState } from "react";
|
||||
import { Image, Pressable, View } from "react-native";
|
||||
|
||||
import { INDEX_BUNDLE_FILE } from "./ErrorCard";
|
||||
|
||||
export default function ErrorStackCard(props: {
|
||||
error: Error & { stack: string };
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
|
||||
let stack: StackFrame[];
|
||||
|
||||
try {
|
||||
const parsedErrorStack = parseErrorStack(props.error.stack);
|
||||
stack = collapsed ? parsedErrorStack.slice(0, 4) : parsedErrorStack;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Card>
|
||||
<View style={{ gap: 12 }}>
|
||||
<Text variant="heading-lg/bold">
|
||||
Call Stack
|
||||
</Text>
|
||||
<View style={{ gap: 4 }}>
|
||||
{stack.map((f, id) => <Line id={id} frame={f} />)}
|
||||
</View>
|
||||
{collapsed && <Text>...</Text>}
|
||||
<View style={{ gap: 8, flexDirection: "row", justifyContent: "center", alignItems: "center" }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
text={`Show ${collapsed ? "more" : "less"}`}
|
||||
icon={collapsed ? findAssetId("down_arrow") : <Image
|
||||
style={{ transform: [{ rotate: `${collapsed ? 0 : 180}deg` }] }}
|
||||
source={findAssetId("down_arrow")!}
|
||||
/>}
|
||||
onPress={() => setCollapsed(v => !v)} />
|
||||
<Button
|
||||
variant="secondary"
|
||||
text="Copy"
|
||||
icon={findAssetId("CopyIcon")}
|
||||
onPress={() => clipboard.setString(props.error.stack)} />
|
||||
</View>
|
||||
</View>
|
||||
</Card>;
|
||||
}
|
||||
function Line(props: { id: number, frame: StackFrame }) {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
|
||||
return <Pressable onPress={() => setCollapsed(v => !v)} key={props.id}>
|
||||
<Text style={{ fontFamily: constants.Fonts.CODE_BOLD }}>
|
||||
{props.frame.methodName}
|
||||
</Text>
|
||||
<Text style={{ fontFamily: constants.Fonts.CODE_NORMAL }} ellipsizeMode="middle" numberOfLines={collapsed ? 1 : undefined}>
|
||||
<Text color="text-muted">{props.frame.file === INDEX_BUNDLE_FILE ? "jsbundle" : props.frame.file}:{props.frame.lineNumber}:{props.frame.column}</Text>
|
||||
</Text>
|
||||
</Pressable>;
|
||||
}
|
||||
|
||||
6
src/core/ui/reporter/utils/isStack.tsx
Normal file
6
src/core/ui/reporter/utils/isStack.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export function isComponentStack(error: Error): error is Error & { componentStack: string; } {
|
||||
return "componentStack" in error && typeof error.componentStack === "string";
|
||||
}
|
||||
export function hasStack(error: Error): error is Error & { stack: string; } {
|
||||
return !!error.stack;
|
||||
}
|
||||
3
src/core/ui/reporter/utils/parseComponentStack.tsx
Normal file
3
src/core/ui/reporter/utils/parseComponentStack.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function parseComponentStack(componentStack: string) {
|
||||
return componentStack.split(/[\s|\n]+?in /).filter(Boolean);
|
||||
}
|
||||
202
src/core/ui/reporter/utils/parseErrorStack.ts
Normal file
202
src/core/ui/reporter/utils/parseErrorStack.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
/**
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* * furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
export type StackFrame = {
|
||||
column?: number,
|
||||
file?: string,
|
||||
lineNumber?: number,
|
||||
methodName: string,
|
||||
collapse?: boolean,
|
||||
};
|
||||
|
||||
type HermesStackLocationNative = {
|
||||
readonly type: "NATIVE",
|
||||
};
|
||||
|
||||
type HermesStackLocationSource = Readonly<{
|
||||
type: "SOURCE",
|
||||
sourceUrl: string,
|
||||
line1Based: number,
|
||||
column1Based: number,
|
||||
}>;
|
||||
|
||||
type HermesStackLocationInternalBytecode = Readonly<{
|
||||
type: "INTERNAL_BYTECODE",
|
||||
sourceUrl: string,
|
||||
line1Based: number,
|
||||
virtualOffset0Based: number,
|
||||
}>;
|
||||
|
||||
type HermesStackLocationBytecode = Readonly<{
|
||||
type: "BYTECODE",
|
||||
sourceUrl: string,
|
||||
line1Based: number,
|
||||
virtualOffset0Based: number,
|
||||
}>;
|
||||
|
||||
type HermesStackLocation =
|
||||
| HermesStackLocationNative
|
||||
| HermesStackLocationSource
|
||||
| HermesStackLocationInternalBytecode
|
||||
| HermesStackLocationBytecode;
|
||||
|
||||
type HermesStackEntryFrame = Readonly<{
|
||||
type: "FRAME",
|
||||
location: HermesStackLocation,
|
||||
functionName: string,
|
||||
}>;
|
||||
|
||||
type HermesStackEntrySkipped = Readonly<{
|
||||
type: "SKIPPED",
|
||||
count: number,
|
||||
}>;
|
||||
|
||||
type HermesStackEntry = HermesStackEntryFrame | HermesStackEntrySkipped;
|
||||
|
||||
export type HermesParsedStack = Readonly<{
|
||||
message: string,
|
||||
entries: ReadonlyArray<HermesStackEntry>,
|
||||
}>;
|
||||
|
||||
// Capturing groups:
|
||||
// 1. function name
|
||||
// 2. is this a native stack frame?
|
||||
// 3. is this a bytecode address or a source location?
|
||||
// 4. source URL (filename)
|
||||
// 5. line number (1 based)
|
||||
// 6. column number (1 based) or virtual offset (0 based)
|
||||
const RE_FRAME =
|
||||
/^ {4}at (.+?)(?: \((native)\)?| \((address at )?(.*?):(\d+):(\d+)\))$/;
|
||||
|
||||
// Capturing groups:
|
||||
// 1. count of skipped frames
|
||||
const RE_SKIPPED = /^ {4}... skipping (\d+) frames$/;
|
||||
const RE_COMPONENT_NO_STACK = /^ {4}at .*$/;
|
||||
|
||||
function isInternalBytecodeSourceUrl(sourceUrl: string): boolean {
|
||||
// See https://github.com/facebook/hermes/blob/3332fa020cae0bab751f648db7c94e1d687eeec7/lib/VM/Runtime.cpp#L1100
|
||||
return sourceUrl === "InternalBytecode.js";
|
||||
}
|
||||
|
||||
function parseLine(line: string): HermesStackEntry | void {
|
||||
const asFrame = line.match(RE_FRAME);
|
||||
if (asFrame) {
|
||||
return {
|
||||
type: "FRAME",
|
||||
functionName: asFrame[1],
|
||||
location:
|
||||
asFrame[2] === "native"
|
||||
? { type: "NATIVE" }
|
||||
: asFrame[3] === "address at "
|
||||
? isInternalBytecodeSourceUrl(asFrame[4])
|
||||
? {
|
||||
type: "INTERNAL_BYTECODE",
|
||||
sourceUrl: asFrame[4],
|
||||
line1Based: Number.parseInt(asFrame[5], 10),
|
||||
virtualOffset0Based: Number.parseInt(asFrame[6], 10),
|
||||
}
|
||||
: {
|
||||
type: "BYTECODE",
|
||||
sourceUrl: asFrame[4],
|
||||
line1Based: Number.parseInt(asFrame[5], 10),
|
||||
virtualOffset0Based: Number.parseInt(asFrame[6], 10),
|
||||
}
|
||||
: {
|
||||
type: "SOURCE",
|
||||
sourceUrl: asFrame[4],
|
||||
line1Based: Number.parseInt(asFrame[5], 10),
|
||||
column1Based: Number.parseInt(asFrame[6], 10),
|
||||
},
|
||||
};
|
||||
}
|
||||
const asSkipped = line.match(RE_SKIPPED);
|
||||
if (asSkipped) {
|
||||
return {
|
||||
type: "SKIPPED",
|
||||
count: Number.parseInt(asSkipped[1], 10),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseHermesStack(stack: string): HermesParsedStack {
|
||||
const lines = stack.split(/\n/);
|
||||
let entries: Array<HermesStackEntryFrame | HermesStackEntrySkipped> = [];
|
||||
let lastMessageLine = -1;
|
||||
for (let i = 0; i < lines.length; ++i) {
|
||||
const line = lines[i];
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
const entry = parseLine(line);
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
continue;
|
||||
}
|
||||
if (RE_COMPONENT_NO_STACK.test(line)) {
|
||||
// Skip component stacks without source location.
|
||||
// TODO: This will not be displayed, not sure how to handle it.
|
||||
continue;
|
||||
}
|
||||
// No match - we're still in the message
|
||||
lastMessageLine = i;
|
||||
entries = [];
|
||||
}
|
||||
const message = lines.slice(0, lastMessageLine + 1).join("\n");
|
||||
return { message, entries };
|
||||
}
|
||||
|
||||
function convertHermesStack(stack: HermesParsedStack): Array<StackFrame> {
|
||||
const frames: Array<StackFrame> = [];
|
||||
for (const entry of stack.entries) {
|
||||
if (entry.type !== "FRAME") {
|
||||
continue;
|
||||
}
|
||||
const { location, functionName } = entry;
|
||||
if (location.type === "NATIVE" || location.type === "INTERNAL_BYTECODE") {
|
||||
continue;
|
||||
}
|
||||
frames.push({
|
||||
methodName: functionName,
|
||||
file: location.sourceUrl,
|
||||
lineNumber: location.line1Based,
|
||||
column:
|
||||
location.type === "SOURCE"
|
||||
? location.column1Based - 1
|
||||
: location.virtualOffset0Based,
|
||||
});
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
export default function parseErrorStack(errorStack?: string): Array<StackFrame> {
|
||||
if (errorStack == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsedStack = Array.isArray(errorStack)
|
||||
? errorStack
|
||||
: convertHermesStack(parseHermesStack(errorStack));
|
||||
|
||||
return parsedStack;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import patchErrorBoundary from "@core/debug/patches/patchErrorBoundary";
|
||||
import initFixes from "@core/fixes";
|
||||
import { initFetchI18nStrings } from "@core/i18n";
|
||||
import initSettings from "@core/ui/settings";
|
||||
|
|
@ -13,7 +14,6 @@ import { writeFile } from "@lib/api/native/fs";
|
|||
import { isPyonLoader, isThemeSupported } from "@lib/api/native/loader";
|
||||
import { patchJsx } from "@lib/api/react/jsx";
|
||||
import { logger } from "@lib/utils/logger";
|
||||
import initSafeMode from "@ui/safeMode";
|
||||
import { patchSettings } from "@ui/settings";
|
||||
|
||||
import * as lib from "./lib";
|
||||
|
|
@ -46,7 +46,7 @@ export default async () => {
|
|||
initFetchI18nStrings(),
|
||||
initSettings(),
|
||||
initFixes(),
|
||||
initSafeMode(),
|
||||
patchErrorBoundary(),
|
||||
updatePlugins()
|
||||
]).then(
|
||||
// Push them all to unloader
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { Strings } from "@core/i18n";
|
||||
import { createLegacyClassComponentStyles, ThemeContext } from "@lib/ui/styles";
|
||||
import ErrorCard from "@core/ui/reporter/components/ErrorCard";
|
||||
import { React } from "@metro/common";
|
||||
import { Button, LegacyFormText } from "@metro/common/components";
|
||||
import { Codeblock } from "@ui/components";
|
||||
import { Falsy, ScrollView } from "react-native";
|
||||
import { ThemeContext } from "@ui/styles";
|
||||
import { Falsy } from "react-native";
|
||||
|
||||
type ErrorBoundaryState = {
|
||||
hasErr: false;
|
||||
|
|
@ -16,19 +14,6 @@ export interface ErrorBoundaryProps {
|
|||
children: JSX.Element | Falsy | (JSX.Element | Falsy)[];
|
||||
}
|
||||
|
||||
const getStyles = createLegacyClassComponentStyles({
|
||||
view: {
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
margin: 10,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
textAlign: "center",
|
||||
marginBottom: 5,
|
||||
},
|
||||
});
|
||||
|
||||
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
|
|
@ -39,24 +24,13 @@ export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, E
|
|||
static getDerivedStateFromError = (error: Error) => ({ hasErr: true, error });
|
||||
|
||||
render() {
|
||||
const styles = getStyles(this.context);
|
||||
if (!this.state.hasErr) return this.props.children;
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.view}>
|
||||
<LegacyFormText style={styles.title}>{Strings.UH_OH}</LegacyFormText>
|
||||
<Codeblock selectable style={{ marginBottom: 5 }}>{this.state.error.name}</Codeblock>
|
||||
<Codeblock selectable style={{ marginBottom: 5 }}>{this.state.error.message}</Codeblock>
|
||||
{this.state.error.stack && <ScrollView style={{ maxHeight: 420, marginBottom: 5 }}>
|
||||
<Codeblock selectable>{this.state.error.stack}</Codeblock>
|
||||
</ScrollView>}
|
||||
<Button
|
||||
size="md"
|
||||
variant="destructive"
|
||||
onPress={() => this.setState({ hasErr: false })}
|
||||
text={Strings.RETRY}
|
||||
/>
|
||||
</ScrollView>
|
||||
<ErrorCard
|
||||
error={this.state.error}
|
||||
onRetryRender={() => this.setState({ hasErr: false })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
import { Strings } from "@core/i18n";
|
||||
import { toggleSafeMode } from "@lib/api/debug";
|
||||
import { DeviceManager } from "@lib/api/native/modules";
|
||||
import { after } from "@lib/api/patcher";
|
||||
import { settings } from "@lib/api/settings";
|
||||
import { lazyDestructure } from "@lib/utils/lazy";
|
||||
import { Button, CompatButton, SafeAreaView } from "@metro/common/components";
|
||||
import { _lazyContextSymbol } from "@metro/lazy";
|
||||
import { LazyModuleContext } from "@metro/types";
|
||||
import { findByNameLazy, findByProps } from "@metro/wrappers";
|
||||
import { semanticColors } from "@ui/color";
|
||||
import { Codeblock, ErrorBoundary as _ErrorBoundary } from "@ui/components";
|
||||
import { createThemedStyleSheet, TextStyleSheet } from "@ui/styles";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
// Let's just pray they have this.
|
||||
const { BadgableTabBar } = lazyDestructure(() => findByProps("BadgableTabBar"));
|
||||
|
||||
const styles = createThemedStyleSheet({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: semanticColors.BACKGROUND_PRIMARY,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
header: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginVertical: 8,
|
||||
},
|
||||
headerTitle: {
|
||||
...TextStyleSheet["heading-md/semibold"],
|
||||
textAlign: "center",
|
||||
textTransform: "uppercase",
|
||||
color: semanticColors.HEADER_PRIMARY,
|
||||
},
|
||||
headerDescription: {
|
||||
...TextStyleSheet["text-sm/medium"],
|
||||
textAlign: "center",
|
||||
color: semanticColors.TEXT_MUTED,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: DeviceManager.isTablet ? "row" : "column",
|
||||
justifyContent: "flex-end",
|
||||
marginVertical: 8,
|
||||
},
|
||||
});
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
title: () => string;
|
||||
trimWhitespace?: boolean;
|
||||
}
|
||||
|
||||
interface Button {
|
||||
text: string;
|
||||
// TODO: Proper types for the below
|
||||
color?: string;
|
||||
size?: string;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ id: "message", title: () => Strings.MESSAGE },
|
||||
{ id: "stack", title: () => Strings.STACK_TRACE },
|
||||
{ id: "componentStack", title: () => Strings.COMPONENT, trimWhitespace: true },
|
||||
];
|
||||
|
||||
function getErrorBoundaryContext() {
|
||||
const ctxt: LazyModuleContext = findByNameLazy("ErrorBoundary")[_lazyContextSymbol];
|
||||
return new Promise(resolve => {
|
||||
ctxt.getExports(exp => {
|
||||
resolve(exp.prototype);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default () => after.await("render", getErrorBoundaryContext(), function (this: any, _, ret) {
|
||||
if (!this.state.error) return;
|
||||
|
||||
// Not using setState here as we don't want to cause a re-render, we want this to be set in the initial render
|
||||
this.state.activeTab ??= "message";
|
||||
const tabData = tabs.find(t => t.id === this.state.activeTab);
|
||||
const errorText: string = this.state.error[this.state.activeTab];
|
||||
|
||||
// This is in the patch and not outside of it so that we can use `this`, e.g. for setting state
|
||||
const buttons: Button[] = [
|
||||
{ text: Strings.RELOAD_DISCORD, onPress: this.handleReload },
|
||||
...!settings.safeMode?.enabled ? [{ text: Strings.RELOAD_IN_SAFE_MODE, onPress: toggleSafeMode }] : [],
|
||||
{ text: Strings.RETRY_RENDER, color: "red", onPress: () => this.setState({ info: null, error: null }) },
|
||||
];
|
||||
|
||||
return (
|
||||
<_ErrorBoundary>
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<ret.props.Illustration style={{ transform: [{ scale: 0.6 }], marginLeft: -40, marginRight: -80 }} />
|
||||
<View style={{ flex: 2, paddingLeft: 24 }}>
|
||||
<Text style={styles.headerTitle}>{ret.props.title}</Text>
|
||||
<Text style={styles.headerDescription}>{ret.props.body}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={{ flex: 6 }}>
|
||||
<View style={{ paddingBottom: 8 }}>
|
||||
{/* Are errors caught by ErrorBoundary guaranteed to have the component stack? */}
|
||||
<BadgableTabBar
|
||||
tabs={tabs.map(t => ({ ...t, title: t.title() }))}
|
||||
activeTab={this.state.activeTab}
|
||||
onTabSelected={(tab: string) => { this.setState({ activeTab: tab }); }}
|
||||
/>
|
||||
</View>
|
||||
<Codeblock
|
||||
selectable
|
||||
style={{ flex: 1, textAlignVertical: "top" }}
|
||||
>
|
||||
{/*
|
||||
TODO: I tried to get this working as intended using regex and failed.
|
||||
When trimWhitespace is true, each line should have it's whitespace removed but with it's spaces kept.
|
||||
*/}
|
||||
{tabData?.trimWhitespace ? errorText.split("\n").filter(i => i.length !== 0).map(i => i.trim()).join("\n") : errorText}
|
||||
</Codeblock>
|
||||
</View>
|
||||
<View style={styles.footer}>
|
||||
{buttons.map(button => {
|
||||
const buttonIndex = buttons.indexOf(button) !== 0 ? 8 : 0;
|
||||
|
||||
return <CompatButton
|
||||
text={button.text}
|
||||
color={button.color ?? "brand"}
|
||||
size={button.size ?? "small"}
|
||||
onPress={button.onPress}
|
||||
style={{
|
||||
...(DeviceManager.isTablet ? {
|
||||
flex: `0.${buttons.length}`,
|
||||
marginLeft: buttonIndex
|
||||
} : {
|
||||
marginTop: buttonIndex
|
||||
}),
|
||||
|
||||
borderRadius: 16
|
||||
}}
|
||||
/>;
|
||||
})}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</_ErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
|
@ -4,9 +4,10 @@ const actionSheet = findByPropsLazy("openLazy", "hideActionSheet");
|
|||
|
||||
export function showSheet<T extends React.ComponentType<any>>(
|
||||
key: string,
|
||||
lazyImport: Promise<{ default: T; }>,
|
||||
lazyImport: Promise<{ default: T; }> | T,
|
||||
props?: React.ComponentProps<T>
|
||||
) {
|
||||
if (!("then" in lazyImport)) lazyImport = Promise.resolve({ default: lazyImport });
|
||||
actionSheet.openLazy(lazyImport, key, props ?? {});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue