mirror of
https://github.com/bitburner-official/bitburner-src.git
synced 2026-04-16 06:18:42 +02:00
UI: Improve Recovery Mode screen (#2206)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user