Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#5530: IDB ErrorBoundary in Extension Console to recover from IDB errors #5600

Merged
merged 5 commits into from
Apr 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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