Skip to content

Commit

Permalink
#5530: IDB ErrorBoundary in Extension Console to recover from IDB err…
Browse files Browse the repository at this point in the history
…ors (#5600)
  • Loading branch information
twschiller authored Apr 23, 2023
1 parent 4168c79 commit 75bb247
Show file tree
Hide file tree
Showing 10 changed files with 655 additions and 83 deletions.
135 changes: 135 additions & 0 deletions src/__snapshots__/Storyshots.test.js.snap

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

77 changes: 68 additions & 9 deletions src/background/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ import {
maybeGetLinkedApiClient,
} from "@/services/apiClient";
import { allowsTrack } from "@/telemetry/dnt";
import { type DBSchema, openDB } from "idb/with-async-ittr";
import { type DBSchema, type IDBPDatabase, openDB } from "idb/with-async-ittr";
import { type UnknownObject } from "@/types/objectTypes";
import { deleteDatabase } from "@/utils/idbUtils";

const UID_STORAGE_KEY = "USER_UUID" as ManualStorageKey;
const EVENT_BUFFER_DEBOUNCE_MS = 2000;
const EVENT_BUFFER_MAX_MS = 10_000;
const TELEMETRY_DB_NAME = "telemetrydb";
Expand All @@ -50,15 +52,46 @@ interface TelemetryDB extends DBSchema {
};
}

/**
* Singleton database connection.
*/
let databaseRef: IDBPDatabase<TelemetryDB> | null = null;

async function openTelemetryDB() {
return openDB<TelemetryDB>(TELEMETRY_DB_NAME, TELEMETRY_DB_VERSION_NUMBER, {
upgrade(db) {
// This is a new DB, so no need to delete existing object store yet
db.createObjectStore(TELEMETRY_EVENT_OBJECT_STORE, {
autoIncrement: true,
});
},
if (databaseRef) {
return databaseRef;
}

databaseRef = await openDB<TelemetryDB>(
TELEMETRY_DB_NAME,
TELEMETRY_DB_VERSION_NUMBER,
{
upgrade(db) {
// This is a new DB, so no need to delete existing object store yet
db.createObjectStore(TELEMETRY_EVENT_OBJECT_STORE, {
autoIncrement: true,
});
},
blocking() {
// Don't block closing/upgrading the database
console.debug("Closing telemetry database due to upgrade/delete");
databaseRef?.close();
databaseRef = null;
},
terminated() {
console.debug(
"Telemetry database connection was unexpectedly terminated"
);
databaseRef = null;
},
}
);

databaseRef.addEventListener("close", () => {
databaseRef = null;
});

return databaseRef;
}

async function addEvent(event: UserTelemetryEvent): Promise<void> {
Expand All @@ -74,7 +107,33 @@ export async function flushEvents(): Promise<UserTelemetryEvent[]> {
return allEvents;
}

const UID_STORAGE_KEY = "USER_UUID" as ManualStorageKey;
/**
* Deletes and recreates the logging database.
*/
export async function recreateDB(): Promise<void> {
await deleteDatabase(TELEMETRY_DB_NAME);

// Open the database to recreate it
await openTelemetryDB();
}

/**
* Returns the number of telemetry entries in the database.
*/
export async function count(): Promise<number> {
const db = await openTelemetryDB();
return db.count(TELEMETRY_EVENT_OBJECT_STORE);
}

/**
* Clears all event entries from the database.
*/
export async function clear(): Promise<void> {
const db = await openTelemetryDB();

const tx = db.transaction(TELEMETRY_EVENT_OBJECT_STORE, "readwrite");
await tx.store.clear();
}

/**
* Return a random ID for this browser profile.
Expand Down
122 changes: 84 additions & 38 deletions src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,68 +23,114 @@ import { getErrorMessage } from "@/errors/errorHelpers";
import { type UnknownObject } from "@/types/objectTypes";
import { isEmpty } from "lodash";

interface Props {
interface DisplayProps {
/**
* Where the error happened, a hint in a free form
*/
errorContext?: string;
}

interface State {
interface BoundaryProps extends DisplayProps {
/**
* Custom error display component
*/
ErrorComponent?: React.FC<DisplayProps & ErrorState>;
}

interface ErrorState {
/**
* True if there was an error. Will always be true in ErrorDisplayProps
*/
hasError: boolean;
errorMessage: string;
stack: string;
/**
* The error object
*/
error: unknown;
/**
* The error message, if available.
* @see getErrorMessage
*/
errorMessage: string | null;
/**
* The error stack trace, if available.
*/
stack: string | null;
}

class ErrorBoundary extends Component<Props, State> {
/**
* Props passed to the ErrorComponent in the ErrorBoundary
* @see ErrorBoundary
*/
export type ErrorDisplayProps = DisplayProps & ErrorState;

/**
* Default error display for use with ErrorBoundary
* @constructor
* @see ErrorBoundary
*/
export const DefaultErrorComponent: React.FC<ErrorDisplayProps> = ({
errorContext,
errorMessage,
stack,
}) => (
<div className="p-3">
<h1>Something went wrong.</h1>
{errorContext && <h2>{errorContext}</h2>}
{!isEmpty(errorMessage) && (
<div>
<p>{errorMessage}</p>
</div>
)}
<div>
<Button
onClick={() => {
location.reload();
}}
>
<FontAwesomeIcon icon={faRedo} /> Reload the Page
</Button>
</div>
{stack && (
<pre className="mt-2 small text-secondary">
{stack
// In the app
.replaceAll(location.origin + "/", "")
// In the content script
.replaceAll(
`chrome-extension://${process.env.CHROME_EXTENSION_ID}/`,
""
)}
</pre>
)}
</div>
);

class ErrorBoundary extends Component<BoundaryProps, ErrorState> {
constructor(props: UnknownObject) {
super(props);
this.state = { hasError: false, errorMessage: undefined, stack: undefined };
this.state = {
hasError: false,
error: undefined,
errorMessage: undefined,
stack: undefined,
};
}

static getDerivedStateFromError(error: Error) {
// Update state so the next render will show the fallback UI.
return {
hasError: true,
error,
errorMessage: getErrorMessage(error),
stack: error.stack,
};
}

override render(): React.ReactNode {
if (this.state.hasError) {
return (
<div className="p-3">
<h1>Something went wrong.</h1>
{this.props.errorContext && <h2>{this.props.errorContext}</h2>}
{!isEmpty(this.state.errorMessage) && (
<div>
<p>{this.state.errorMessage}</p>
</div>
)}
<div>
<Button
onClick={() => {
location.reload();
}}
>
<FontAwesomeIcon icon={faRedo} /> Reload the Page
</Button>
</div>
{this.state.stack && (
<pre className="mt-2 small text-secondary">
{this.state.stack
// In the app
.replaceAll(location.origin + "/", "")
// In the content script
.replaceAll(
`chrome-extension://${process.env.CHROME_EXTENSION_ID}/`,
""
)}
</pre>
)}
</div>
);
const ErrorComponent = this.props.ErrorComponent ?? DefaultErrorComponent;

return <ErrorComponent {...this.props} {...this.state} />;
}

return this.props.children;
Expand Down
Loading

0 comments on commit 75bb247

Please sign in to comment.