UI: Improve Recovery Mode screen (#2206)

This commit is contained in:
catloversg
2025-06-17 06:38:51 +07:00
committed by GitHub
parent a0740d72a9
commit cf72937faf
3 changed files with 127 additions and 98 deletions

View File

@@ -1,8 +1,8 @@
import React, { ErrorInfo } from "react";
import React, { type ErrorInfo } from "react";
import { IErrorData, getErrorForDisplay } from "../utils/ErrorHelper";
import { type CrashReport, getCrashReport } from "../utils/ErrorHelper";
import { RecoveryRoot } from "./React/RecoveryRoot";
import { Page } from "./Router";
import type { Page } from "./Router";
import { Router } from "./GameRoot";
type ErrorBoundaryProps = {
@@ -12,7 +12,7 @@ type ErrorBoundaryProps = {
type ErrorBoundaryState = {
error?: Error;
errorInfo?: React.ErrorInfo;
reactErrorInfo?: ErrorInfo;
page?: Page;
hasError: boolean;
};
@@ -27,40 +27,42 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
this.setState({ hasError: false });
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
componentDidCatch(error: Error, reactErrorInfo: ErrorInfo): void {
this.setState({
errorInfo,
reactErrorInfo: reactErrorInfo,
page: Router.page(),
});
console.error(error, errorInfo);
console.error(error, reactErrorInfo);
}
/**
* When an error is thrown, this function is called twice and renders RecoveryRoot with two different errorData, even
* when there is only one error. The flow is roughly like this:
* When an error is thrown, this function is called twice and renders RecoveryRoot with two different crashReport,
* even when there is only one error. The flow is roughly like this:
* - The error is thrown.
* - getDerivedStateFromError() -> Set hasError and error
* - render() -> Render RecoveryRoot with errorData1, which does not contain errorInfo and page
* - componentDidCatch() -> Set errorInfo and page
* - render() -> Render RecoveryRoot with errorData2, which contains errorInfo and page
* - render() -> Render RecoveryRoot with crashReport1, which does not contain reactErrorInfo and page
* - componentDidCatch() -> Set reactErrorInfo and page
* - render() -> Render RecoveryRoot with crashReport2, which contains reactErrorInfo and page
*
* This means that if we use useEffect(()=>{}, [errorData]) in RecoveryRoot, that hook will be called twice with 2
* different errorData. The second errorData, which contains errorInfo and page, is the "final" value that is shown on
* the recovery screen.
* This means that if we use useEffect(()=>{}, [crashReport]) in RecoveryRoot, that hook will be called twice with 2
* different crashReport. The second crashReport, which contains reactErrorInfo and page, is the "final" value that is
* shown on the recovery screen.
*/
render(): React.ReactNode {
if (this.state.hasError) {
let errorData: IErrorData | undefined;
let crashReport: CrashReport | undefined;
if (this.state.error) {
try {
// We don't want recursive errors, so in case this fails, it's in a try catch.
errorData = getErrorForDisplay(this.state.error, this.state.errorInfo, this.state.page);
crashReport = getCrashReport(this.state.error, this.state.reactErrorInfo, this.state.page);
} catch (ex) {
console.error(ex);
}
}
return <RecoveryRoot softReset={this.props.softReset} errorData={errorData} resetError={() => this.reset()} />;
return (
<RecoveryRoot softReset={this.props.softReset} crashReport={crashReport} resetError={() => this.reset()} />
);
}
return this.props.children;
}

View File

@@ -5,7 +5,7 @@ import { Settings } from "../../Settings/Settings";
import { load } from "../../db";
import { Router } from "../GameRoot";
import { Page } from "../Router";
import { IErrorData, newIssueUrl, getErrorForDisplay } from "../../utils/ErrorHelper";
import { type CrashReport, newIssueUrl, getCrashReport } from "../../utils/ErrorHelper";
import { DeleteGameButton } from "./DeleteGameButton";
import { SoftResetButton } from "./SoftResetButton";
@@ -27,7 +27,7 @@ export function ActivateRecoveryMode(error: unknown): void {
interface IProps {
softReset: () => void;
errorData?: IErrorData;
crashReport?: CrashReport;
resetError?: () => void;
}
@@ -43,12 +43,13 @@ function exportSaveFile(): void {
});
}
function exportCrashReport(crashReportBody: string): void {
downloadContentAsFile(crashReportBody, `CRASH_REPORT_BITBURNER_${Date.now()}.txt`);
}
const debouncedExportSaveFile = debounce(exportSaveFile, 1000);
const debouncedExportCrashReport = debounce((crashReport: unknown) => {
const content = typeof crashReport === "object" ? JSON.stringify(crashReport) : String(crashReport);
downloadContentAsFile(content, `CRASH_REPORT_BITBURNER_${Date.now()}.txt`);
}, 2000);
const debouncedExportCrashReport = debounce(exportCrashReport, 2000);
/**
* The recovery screen can be activated in 2 ways:
@@ -58,11 +59,11 @@ const debouncedExportCrashReport = debounce((crashReport: unknown) => {
* - isBitNodeFinished() throws an error in src\ui\GameRoot.tsx.
* - ErrorBoundary [2]: After loading the save data and GameRoot is rendered, an error is thrown anywhere else.
*
* [1]: errorData is undefined and sourceError, which is the error thrown in LoadingScreen.tsx, is set via ActivateRecoveryMode().
* [2]: RecoveryRoot is rendered twice with 2 different errorData. For more information, please check the comment in
* [1]: crashReport is undefined and sourceError, which is the error thrown in LoadingScreen.tsx, is set via ActivateRecoveryMode().
* [2]: RecoveryRoot is rendered twice with 2 different crashReport. For more information, please check the comment in
* src\ui\ErrorBoundary.tsx.
*/
export function RecoveryRoot({ softReset, errorData, resetError }: IProps): React.ReactElement {
export function RecoveryRoot({ softReset, crashReport, resetError }: IProps): React.ReactElement {
function recover(): void {
if (resetError) resetError();
RecoveryMode = false;
@@ -71,9 +72,9 @@ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): Reac
}
Settings.AutosaveInterval = 0;
// This happens in [1] mentioned above. errorData is undefined, so we need to parse sourceError to get errorData.
if (errorData == null && sourceError) {
errorData = getErrorForDisplay(sourceError, undefined, Page.LoadingScreen);
// This happens in [1] mentioned above. crashReport is undefined, so we need to parse sourceError to get crashReport.
if (crashReport == null && sourceError) {
crashReport = getCrashReport(sourceError, undefined, Page.LoadingScreen);
}
useEffect(() => {
@@ -81,32 +82,38 @@ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): Reac
debouncedExportSaveFile();
/**
* This hook can be called with 3 types of errorData:
* - In [1]: errorData.metadata.page is Page.LoadingScreen
* This hook can be called with 3 types of crashReport:
* - In [1]: crashReport.metadata.page is Page.LoadingScreen
* - In [2]:
* - First render: errorData.metadata.errorInfo is undefined
* - Second render: errorData.metadata.errorInfo contains componentStack
* - First render: crashReport.metadata.reactErrorInfo is undefined
* - Second render: crashReport.metadata.reactErrorInfo contains componentStack
*
* The following check makes sure that we do not write the crash report in the "first render" of [2].
*/
if (errorData && (errorData.metadata.errorInfo || errorData.metadata.page === Page.LoadingScreen)) {
debouncedExportCrashReport(errorData.body);
if (crashReport && (crashReport.metadata.reactErrorInfo || crashReport.metadata.page === Page.LoadingScreen)) {
debouncedExportCrashReport(crashReport.body);
}
}, [errorData]);
}, [crashReport]);
let instructions;
if (sourceError instanceof UnsupportedSaveData) {
instructions = <Typography variant="h6">Please update your browser.</Typography>;
instructions = (
<Typography variant="h4" color={Settings.theme.warning}>
Please update your browser.
</Typography>
);
} else if (sourceError instanceof InvalidSaveData) {
instructions = (
<Typography variant="h6">Your save data is invalid. Please import a valid backup save file.</Typography>
<Typography variant="h4" color={Settings.theme.warning}>
Your save data is invalid. Please import a valid backup save file.
</Typography>
);
} else {
instructions = (
<Box>
<Typography>It is recommended to alert a developer.</Typography>
<Typography>
<Link href={errorData?.issueUrl ?? newIssueUrl} target="_blank">
<Link href={crashReport?.issueUrl ?? newIssueUrl} target="_blank">
File an issue on github
</Link>
</Typography>
@@ -120,7 +127,9 @@ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): Reac
Make a reddit post
</Link>
</Typography>
<Typography>Please include your save file and the crash report.</Typography>
<Typography variant="h4" color={Settings.theme.warning}>
Please include your save file and the crash report.
</Typography>
</Box>
);
}
@@ -133,7 +142,7 @@ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): Reac
const canDisableRecoveryMode = Engine.isRunning;
return (
<Box sx={{ padding: "8px 16px", minHeight: "100vh", maxWidth: "1200px", boxSizing: "border-box" }}>
<Box sx={{ padding: "8px 16px", minHeight: "100vh", boxSizing: "border-box" }}>
<Typography variant="h3">RECOVERY MODE ACTIVATED</Typography>
<Typography>
There was an error with your save file and the game went into recovery mode. In this mode, saving is disabled
@@ -149,9 +158,14 @@ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): Reac
</Box>
)}
{instructions}
<br />
<Button onClick={exportSaveFile}>Export save file</Button>
<br />
<div>
<Button onClick={exportSaveFile}>Export save file</Button>
{crashReport && (
<Button onClick={() => exportCrashReport(crashReport.body)} style={{ marginLeft: "20px" }}>
Export crash report
</Button>
)}
</div>
<br />
{canDisableRecoveryMode && (
<Typography>
@@ -170,35 +184,49 @@ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): Reac
<DeleteGameButton color="error" />
</ButtonGroup>
{errorData && (
<Paper sx={{ px: 2, pt: 1, pb: 2, mt: 2 }}>
<Typography variant="h5">{errorData.title}</Typography>
<Box sx={{ my: 2 }}>
<TextField
label="Bug Report Text"
value={errorData.body}
variant="outlined"
color="secondary"
multiline
fullWidth
rows={12}
spellCheck={false}
sx={{ "& .MuiOutlinedInput-root": { color: Settings.theme.secondary } }}
/>
</Box>
<Tooltip title="Submitting an issue to GitHub really helps us improve the game!">
<Button
component={Link}
startIcon={<GitHubIcon />}
color="info"
sx={{ px: 2 }}
href={errorData.issueUrl ?? newIssueUrl}
target={"_blank"}
>
Submit Issue to GitHub
</Button>
</Tooltip>
</Paper>
{crashReport && (
<>
{crashReport.metadata.error.stack && (
<Paper>
<TextField
label="Stack Trace"
value={crashReport.metadata.error.stack}
variant="outlined"
multiline
fullWidth
spellCheck={false}
/>
</Paper>
)}
<Paper sx={{ px: 2, pt: 1, pb: 2, mt: 2 }}>
<Typography variant="h5">{crashReport.title}</Typography>
<Box sx={{ my: 2 }}>
<TextField
label="Bug Report Text"
value={crashReport.body}
variant="outlined"
color="secondary"
multiline
fullWidth
rows={40}
spellCheck={false}
sx={{ "& .MuiOutlinedInput-root": { color: Settings.theme.secondary } }}
/>
</Box>
<Tooltip title="Submitting an issue to GitHub really helps us improve the game!">
<Button
component={Link}
startIcon={<GitHubIcon />}
color="info"
sx={{ px: 2 }}
href={crashReport.issueUrl ?? newIssueUrl}
target={"_blank"}
>
Submit Issue to GitHub
</Button>
</Tooltip>
</Paper>
</>
)}
</Box>
);

View File

@@ -29,26 +29,23 @@ interface BrowserFeatures {
indexedDb: boolean;
}
interface IErrorMetadata {
interface CrashReportMetadata {
error: Record<string, unknown>;
errorInfo?: React.ErrorInfo;
reactErrorInfo?: React.ErrorInfo;
page?: Page;
environment: GameEnv;
platform: Platform;
version: GameVersion;
features: BrowserFeatures;
browserFeatures: BrowserFeatures;
}
export interface IErrorData {
metadata: IErrorMetadata;
export interface CrashReport {
metadata: CrashReportMetadata;
title: string;
body: string;
features: string;
fileName?: string;
issueUrl: string;
}
@@ -96,15 +93,19 @@ export function getErrorMessageWithStackAndCause(error: unknown, prefix = ""): s
return errorMessage;
}
export function getErrorMetadata(error: unknown, errorInfo?: React.ErrorInfo, page?: Page): IErrorMetadata {
export function getCrashReportMetadata(
error: unknown,
reactErrorInfo?: React.ErrorInfo,
page?: Page,
): CrashReportMetadata {
const isElectron = navigator.userAgent.toLowerCase().includes(" electron/");
const env = process.env.NODE_ENV === "development" ? GameEnv.Development : GameEnv.Production;
const version: GameVersion = {
const version = {
version: CONSTANTS.VersionString,
commitHash: commitHash(),
toDisplay: () => `v${CONSTANTS.VersionString} (${commitHash()})`,
};
const features: BrowserFeatures = {
const browserFeatures = {
userAgent: navigator.userAgent,
language: navigator.language,
@@ -113,25 +114,26 @@ export function getErrorMetadata(error: unknown, errorInfo?: React.ErrorInfo, pa
indexedDb: !!window.indexedDB,
};
const errorObj = typeof error === "object" && error !== null ? (error as Record<string, unknown>) : {};
const metadata: IErrorMetadata = {
return {
platform: isElectron ? Platform.Steam : Platform.Browser,
environment: env,
version,
features,
browserFeatures,
error: errorObj,
errorInfo,
reactErrorInfo,
page,
};
return metadata;
}
export function getErrorForDisplay(error: unknown, errorInfo?: React.ErrorInfo, page?: Page): IErrorData {
const metadata = getErrorMetadata(error, errorInfo, page);
export function getCrashReport(error: unknown, reactErrorInfo?: React.ErrorInfo, page?: Page): CrashReport {
const metadata = getCrashReportMetadata(error, reactErrorInfo, page);
const errorData = parseUnknownError(error);
const fileName = String(metadata.error.fileName);
const features =
`lang=${metadata.features.language} cookiesEnabled=${metadata.features.cookiesEnabled.toString()}` +
` doNotTrack=${metadata.features.doNotTrack ?? "null"} indexedDb=${metadata.features.indexedDb.toString()}`;
`lang=${metadata.browserFeatures.language} cookiesEnabled=${metadata.browserFeatures.cookiesEnabled.toString()}` +
` doNotTrack=${
metadata.browserFeatures.doNotTrack ?? "null"
} indexedDb=${metadata.browserFeatures.indexedDb.toString()}`;
const title = `${metadata.error.name}: ${metadata.error.message} (at "${metadata.page}")`;
let causeAndCauseStack = errorData.causeAsString
@@ -175,7 +177,7 @@ ${errorData.stack}
${causeAndCauseStack}
### React Component Stack
\`\`\`
${metadata.errorInfo?.componentStack}
${metadata.reactErrorInfo?.componentStack}
\`\`\`
### Save
@@ -186,13 +188,10 @@ Copy your save here if possible
const issueUrl = `${newIssueUrl}?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
const data: IErrorData = {
return {
metadata,
fileName,
features,
title,
body,
issueUrl,
};
return data;
}