Skip to content

Commit

Permalink
refactor: better error display and other minor adjustments (#402)
Browse files Browse the repository at this point in the history
  • Loading branch information
Charlie-XIAO authored Feb 3, 2025
1 parent c8bf6b1 commit 59e59ec
Show file tree
Hide file tree
Showing 8 changed files with 90 additions and 70 deletions.
2 changes: 1 addition & 1 deletion crates/deskulpt-core/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use tauri::{App, AppHandle, Emitter, Runtime};

/// Payload of the `show-toast` event.
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
#[serde(tag = "type", content = "content", rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ShowToastPayload {
/// Show a [success](https://sonner.emilkowal.ski/toast#success) toast.
Success(String),
Expand Down
42 changes: 28 additions & 14 deletions src/canvas/components/ErrorDisplay.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,39 @@
import { Box, Code, Flex, Heading, ScrollArea } from "@radix-ui/themes";
import { Box, Code, Dialog, ScrollArea, Text } from "@radix-ui/themes";
import { memo } from "react";

interface ErrorDisplayProps {
id: string;
error: string;
message: string;
}

const ErrorDisplay = memo(({ id, error }: ErrorDisplayProps) => {
const ErrorDisplay = memo(({ id, error, message }: ErrorDisplayProps) => {
return (
<ScrollArea scrollbars="both" asChild>
<Box p="2">
<Flex direction="column" gap="2">
<Heading size="2" color="red" css={{ whiteSpace: "pre" }}>
{id}-{id}
</Heading>
<Code size="2" variant="ghost" css={{ whiteSpace: "pre" }}>
{error}
</Code>
</Flex>
</Box>
</ScrollArea>
<Dialog.Root>
<Dialog.Trigger>
<Box width="100%" height="100%" p="2" css={{ cursor: "pointer" }}>
<Text size="2" color="red">
An error occurred in widget <Code variant="ghost">{id}</Code>. Click
anywhere to check the details.
</Text>
</Box>
</Dialog.Trigger>
<Dialog.Content size="2" maxWidth="60vw">
<Dialog.Title size="3" color="red" mb="1">
Error in widget <Code variant="ghost">{id}</Code>
</Dialog.Title>
<Dialog.Description size="2" color="red" mb="2">
{error}
</Dialog.Description>
<ScrollArea asChild>
<Box pb="3" pr="3" maxHeight="50vh">
<Code size="2" variant="ghost" css={{ whiteSpace: "pre" }}>
{message}
</Code>
</Box>
</ScrollArea>
</Dialog.Content>
</Dialog.Root>
);
});

Expand Down
8 changes: 6 additions & 2 deletions src/canvas/components/WidgetContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { RefObject, memo, useCallback, useRef } from "react";
import Draggable, { DraggableData, DraggableEvent } from "react-draggable";
import { ErrorBoundary } from "react-error-boundary";
import ErrorDisplay from "../components/ErrorDisplay";
import ErrorDisplay from "./ErrorDisplay";
import { stringifyError } from "../utils";
import { LuGripVertical } from "react-icons/lu";
import { Box } from "@radix-ui/themes";
Expand Down Expand Up @@ -75,7 +75,11 @@ const WidgetContainer = memo(({ id }: WidgetContainerProps) => {
<ErrorBoundary
resetKeys={[Component]}
fallbackRender={({ error }) => (
<ErrorDisplay id={id} error={stringifyError(error)} />
<ErrorDisplay
id={id}
error="Error in the widget component [React error boundary]"
message={stringifyError(error)}
/>
)}
>
<Component id={id} />
Expand Down
32 changes: 21 additions & 11 deletions src/canvas/hooks/useRenderWidgetsListener.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { useEffect, useRef } from "react";
import { listenToRenderWidgets } from "../../events";
import { invokeBundleWidget, invokeSetRenderReady } from "../../commands";
import {
Widget,
updateWidgetRender,
updateWidgetRenderError,
useWidgetsStore,
} from "./useWidgetsStore";
import { stringifyError } from "../utils";

const BASE_URL = new URL(import.meta.url).origin;
const RAW_APIS_URL = new URL("/generated/raw-apis.js", BASE_URL).href;
Expand Down Expand Up @@ -48,7 +48,13 @@ export function useRenderWidgetsListener() {
apisBlobUrl,
});
} catch (error) {
updateWidgetRenderError(id, error, apisBlobUrl, settings);
updateWidgetRenderError(
id,
"Error bundling the widget",
stringifyError(error),
apisBlobUrl,
settings,
);
return;
}
}
Expand All @@ -58,31 +64,35 @@ export function useRenderWidgetsListener() {
let module;
try {
module = await import(/* @vite-ignore */ moduleBlobUrl);
if (module.default === undefined) {
throw new Error("Missing default export");
}
} catch (error) {
URL.revokeObjectURL(moduleBlobUrl);
updateWidgetRenderError(id, error, apisBlobUrl, settings);
return;
}

const widget = module.default as Widget;
if (widget === undefined || widget.Component === undefined) {
URL.revokeObjectURL(moduleBlobUrl);
updateWidgetRenderError(
id,
"The widget must provide a default export with a `Component` property.",
"Error importing the widget module",
stringifyError(error),
apisBlobUrl,
settings,
);
return;
}

updateWidgetRender(id, widget, moduleBlobUrl, apisBlobUrl, settings);
updateWidgetRender(
id,
module.default,
moduleBlobUrl,
apisBlobUrl,
settings,
);
});

await Promise.all(promises);
});

if (!hasInited.current) {
// Set the canvas as ready to render only once
invokeSetRenderReady()
.then(() => {
hasInited.current = true;
Expand Down
13 changes: 9 additions & 4 deletions src/canvas/hooks/useShowToastListener.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { useEffect } from "react";
import { listenToShowToast } from "../../events";
import { toast } from "sonner";
import { ShowToastPayloadType } from "../../types/backend";

export function useShowToastListener() {
useEffect(() => {
const unlisten = listenToShowToast((event) => {
if ("success" in event.payload) {
void toast.success(event.payload.success);
} else if ("error" in event.payload) {
void toast.error(event.payload.error);
const { type, content } = event.payload;
switch (type) {
case ShowToastPayloadType.SUCCESS:
void toast.success(content);
break;
case ShowToastPayloadType.ERROR:
void toast.error(content);
break;
}
});

Expand Down
45 changes: 16 additions & 29 deletions src/canvas/hooks/useWidgetsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@ import { create } from "zustand";
import { WidgetSettings } from "../../types/backend";
import { FC, createElement } from "react";
import ErrorDisplay from "../components/ErrorDisplay";
import { stringifyError } from "../utils";

export interface Widget {
interface Widget {
Component: FC<{ id: string }>;
width?: string;
height?: string;
}

export interface WidgetState extends Widget, WidgetSettings {
interface WidgetState extends Widget, WidgetSettings {
apisBlobUrl: string;
moduleBlobUrl?: string;
}
Expand All @@ -19,13 +18,6 @@ export const useWidgetsStore = create(() => ({
widgets: {} as Record<string, WidgetState>,
}));

/**
* Update rendering information of a widget.
*
* If the widget is in the store, rendering information will be updated, and the
* settings will be ignored. Otherwise, the settings are required and a new
* widget will be added to the store.
*/
export function updateWidgetRender(
id: string,
widget: Widget,
Expand All @@ -34,6 +26,7 @@ export function updateWidgetRender(
settings?: WidgetSettings,
) {
useWidgetsStore.setState((state) => {
// Settings are ignored if the widget is already in the store
if (id in state.widgets) {
return {
widgets: {
Expand All @@ -50,6 +43,8 @@ export function updateWidgetRender(
},
};
}

// Settings are required if the widget is newly added
if (settings !== undefined) {
return {
widgets: {
Expand All @@ -58,59 +53,55 @@ export function updateWidgetRender(
},
};
}

return state;
});
}

/**
* Update rendering error of a widget.
*
* If the widget is in the store, its rendering information will be overridden
* with the error and the settings will be ignored. Otherwise, the settings are
* required and a new widget will be added to the store with the error.
*/
export function updateWidgetRenderError(
id: string,
error: unknown,
error: string,
message: string,
apisBlobUrl: string,
settings?: WidgetSettings,
) {
useWidgetsStore.setState((state) => {
// Settings are ignored if the widget is already in the store
if (id in state.widgets) {
return {
widgets: {
...state.widgets,
[id]: {
...state.widgets[id],
Component: () =>
createElement(ErrorDisplay, { id, error: stringifyError(error) }),
createElement(ErrorDisplay, { id, error, message }),
width: undefined,
height: undefined,
moduleBlobUrl: undefined,
},
},
};
}

// Settings are required if the widget is newly added
if (settings !== undefined) {
return {
widgets: {
...state.widgets,
[id]: {
...settings,
Component: () =>
createElement(ErrorDisplay, { id, error: stringifyError(error) }),
createElement(ErrorDisplay, { id, error, message }),
apisBlobUrl,
},
},
};
}

return state;
});
}

/**
* Update (partial) settings of a widget.
*/
export function updateWidgetSettings(
id: string,
settings: Partial<WidgetSettings>,
Expand All @@ -128,21 +119,17 @@ export function updateWidgetSettings(
});
}

/**
* Remove a batch of widgets from the store.
*/
export function removeWidgets(ids: string[]) {
const widgets = useWidgetsStore.getState().widgets;

// Revoke object URLs for the widgets being removed
ids.forEach((id) => {
const widget = widgets[id];
if (widget === undefined) {
return; // This should not happen but let us be safe
}
URL.revokeObjectURL(widget.apisBlobUrl);
if (widget.moduleBlobUrl !== undefined) {
URL.revokeObjectURL(widget.moduleBlobUrl);
}
widget.moduleBlobUrl && URL.revokeObjectURL(widget.moduleBlobUrl);
});

useWidgetsStore.setState((state) => ({
Expand Down
9 changes: 1 addition & 8 deletions src/canvas/utils.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
/**
* Stringify an unknown error into a human-readable format.
*
* A string error will be returned as is. An `Error` object will return its
* stack if available, otherwise its message. If the error does not fall into
* any of the above categories, a generic message will be returned.
*/
export function stringifyError(err: unknown) {
if (typeof err === "string") {
return err;
}
if (err instanceof Error) {
return err.stack ?? err.message;
}
return "Unknown error caught that is neither a string nor an Error";
return "Unknown error that is neither a string nor an Error instance";
}
9 changes: 8 additions & 1 deletion src/types/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@
* This file contains the types and interfaces that have backend counterparts.
*/

export type ShowToastPayload = { success: string } | { error: string };
export enum ShowToastPayloadType {
SUCCESS = "SUCCESS",
ERROR = "ERROR",
}

export type ShowToastPayload =
| { type: ShowToastPayloadType.SUCCESS; content: string }
| { type: ShowToastPayloadType.ERROR; content: string };

export enum WidgetConfigType {
VALID = "VALID",
Expand Down

0 comments on commit 59e59ec

Please sign in to comment.