From cf72937faf62ada553236e9a5eac76f48b25df69 Mon Sep 17 00:00:00 2001 From: catloversg <152669316+catloversg@users.noreply.github.com> Date: Tue, 17 Jun 2025 06:38:51 +0700 Subject: [PATCH] UI: Improve Recovery Mode screen (#2206) --- src/ui/ErrorBoundary.tsx | 38 ++++----- src/ui/React/RecoveryRoot.tsx | 140 ++++++++++++++++++++-------------- src/utils/ErrorHelper.ts | 47 ++++++------ 3 files changed, 127 insertions(+), 98 deletions(-) diff --git a/src/ui/ErrorBoundary.tsx b/src/ui/ErrorBoundary.tsx index a3e542d12..106b8d01f 100644 --- a/src/ui/ErrorBoundary.tsx +++ b/src/ui/ErrorBoundary.tsx @@ -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 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 this.reset()} />; + return ( + this.reset()} /> + ); } return this.props.children; } diff --git a/src/ui/React/RecoveryRoot.tsx b/src/ui/React/RecoveryRoot.tsx index f7c7e28f8..6d646f23a 100644 --- a/src/ui/React/RecoveryRoot.tsx +++ b/src/ui/React/RecoveryRoot.tsx @@ -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 = Please update your browser.; + instructions = ( + + Please update your browser. + + ); } else if (sourceError instanceof InvalidSaveData) { instructions = ( - Your save data is invalid. Please import a valid backup save file. + + Your save data is invalid. Please import a valid backup save file. + ); } else { instructions = ( It is recommended to alert a developer. - + File an issue on github @@ -120,7 +127,9 @@ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): Reac Make a reddit post - Please include your save file and the crash report. + + Please include your save file and the crash report. + ); } @@ -133,7 +142,7 @@ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): Reac const canDisableRecoveryMode = Engine.isRunning; return ( - + RECOVERY MODE ACTIVATED 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 )} {instructions} -
- -
+
+ + {crashReport && ( + + )} +

{canDisableRecoveryMode && ( @@ -170,35 +184,49 @@ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): Reac - {errorData && ( - - {errorData.title} - - - - - - - + {crashReport && ( + <> + {crashReport.metadata.error.stack && ( + + + + )} + + {crashReport.title} + + + + + + + + )}
); diff --git a/src/utils/ErrorHelper.ts b/src/utils/ErrorHelper.ts index a92ed6617..5408a39ac 100644 --- a/src/utils/ErrorHelper.ts +++ b/src/utils/ErrorHelper.ts @@ -29,26 +29,23 @@ interface BrowserFeatures { indexedDb: boolean; } -interface IErrorMetadata { +interface CrashReportMetadata { error: Record; - 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) : {}; - 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; }