Repository: temporalio/ui
Pull request #258 (2022-02-10 00:16:53)

Initial commit with functional errors from client/server side.
<!--- Note to EXTERNAL Contributors -->
<!-- Thanks for opening a PR! 
If it is a significant code change, please **make sure there is an open issue** for this. 
We work best with you when we have accepted the idea first before you code. -->

<!--- For ALL Contributors 👇 -->

## What was changed
<!-- Describe what has changed in this PR -->

## Why?
<!-- Tell your future self why have you made these changes -->

## Checklist
<!--- add/delete as needed --->

1. Closes <!-- add issue number here -->

2. How was this tested:
<!--- Please describe how you tested your changes/how we can test them -->

3. Any docs updates needed?
<!--- update README if applicable
      or point out where to update docs.temporal.io


 -->
https://user-images.githubusercontent.com/1903839/153312860-33481f31-25ca-4a41-b4d2-f7be45d19f6b.mov


cloudflare-pages[bot]: ## Deploying with &nbsp;<a href="https://pages.dev"><img alt="Cloudflare Pages" src="https://user-images.githubusercontent.com/23264/106598434-9e719e00-654f-11eb-9e59-6167043cfa01.png" width="16"></a> &nbsp;Cloudflare Pages

<table><tr><td><strong>Latest commit:</strong> </td><td>
<code>b72cc88</code>
</td></tr>
<tr><td><strong>Status:</strong></td><td>⚡️&nbsp; Build in progress...</td></tr>
</table>

[View logs](https://dash.cloudflare.com/?to=/:account/pages/view/temporal-pizza/2cce7e59-2f2b-4742-bc95-3b3879557dd1)


Patch:
diff --git a/package-lock.json b/package-lock.json
index d6054444..d4182f1f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
         "websocket-as-promised": "^2.0.1"
       },
       "devDependencies": {
+        "@crownframework/svelte-error-boundary": "^1.0.3",
         "@grpc/grpc-js": "^1.3.7",
         "@sveltejs/adapter-static": "^1.0.0-next.13",
         "@sveltejs/kit": "next",
@@ -719,6 +720,12 @@
         "node": ">=0.1.95"
       }
     },
+    "node_modules/@crownframework/svelte-error-boundary": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@crownframework/svelte-error-boundary/-/svelte-error-boundary-1.0.3.tgz",
+      "integrity": "sha512-ZtfYF9if48Ok8BTx/fn7bERJFKHKO6Ih5d/fkY6/pC3DQEnJq4vy6JOBFcisnpKYcZbjG5F/D4r06kqX7JKyqw==",
+      "dev": true
+    },
     "node_modules/@cspotcode/source-map-consumer": {
       "version": "0.8.0",
       "resolved": "https://registry.npmjs.org/@cspotcode/source-map-consumer/-/source-map-consumer-0.8.0.tgz",
@@ -12065,6 +12072,12 @@
         "minimist": "^1.2.0"
       }
     },
+    "@crownframework/svelte-error-boundary": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@crownframework/svelte-error-boundary/-/svelte-error-boundary-1.0.3.tgz",
+      "integrity": "sha512-ZtfYF9if48Ok8BTx/fn7bERJFKHKO6Ih5d/fkY6/pC3DQEnJq4vy6JOBFcisnpKYcZbjG5F/D4r06kqX7JKyqw==",
+      "dev": true
+    },
     "@cspotcode/source-map-consumer": {
       "version": "0.8.0",
       "resolved": "https://registry.npmjs.org/@cspotcode/source-map-consumer/-/source-map-consumer-0.8.0.tgz",
diff --git a/package.json b/package.json
index 5038fe61..bcb6705c 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
     "websocket-as-promised": "^2.0.1"
   },
   "devDependencies": {
+    "@crownframework/svelte-error-boundary": "^1.0.3",
     "@grpc/grpc-js": "^1.3.7",
     "@sveltejs/adapter-static": "^1.0.0-next.13",
     "@sveltejs/kit": "next",
diff --git a/src/global.d.ts b/src/global.d.ts
index 9c0abb37..3e56f4c7 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -45,6 +45,12 @@ type PaginationCallbacks<T> = {
   onError?: (error: unknown) => void;
 };
 
+interface NetworkError {
+  statusCode: number;
+  statusText: string;
+  response: Response;
+}
+
 type WorkflowType = string | null;
 
 type WorkflowExecutionFilters = {
@@ -79,3 +85,5 @@ type User = {
   name: string;
   picture: string;
 };
+
+declare module '@crownframework/svelte-error-boundary';
diff --git a/src/lib/components/error-boundary.svelte b/src/lib/components/error-boundary.svelte
new file mode 100644
index 00000000..4829999f
--- /dev/null
+++ b/src/lib/components/error-boundary.svelte
@@ -0,0 +1,38 @@
+<script lang="ts">
+  import { networkError } from '$lib/stores/error';
+  import { isNetworkError } from '$lib/utilities/is-network-error';
+  import Error from './error.svelte';
+
+  export let error = null;
+  export let onError = null;
+
+  let theError;
+
+  $: {
+    if (error && $error) {
+      theError = error;
+    }
+    if (networkError && $networkError) {
+      theError = networkError;
+    }
+
+    if (onError && theError) {
+      onError(theError);
+    }
+  }
+</script>
+
+{#if $theError}
+  <Error error={$theError} />
+{:else}
+  <slot />
+{/if}
+
+<style>
+  .error {
+    border: 1px solid red;
+  }
+  .trace {
+    font-family: monospace;
+  }
+</style>
diff --git a/src/lib/components/error-boundary.ts b/src/lib/components/error-boundary.ts
new file mode 100644
index 00000000..c14ad969
--- /dev/null
+++ b/src/lib/components/error-boundary.ts
@@ -0,0 +1,4 @@
+import Component from './error-boundary.svelte';
+import { createBoundary } from '@crownframework/svelte-error-boundary';
+
+export const ErrorBoundary = createBoundary(Component);
diff --git a/src/lib/components/error.svelte b/src/lib/components/error.svelte
new file mode 100644
index 00000000..c41e9f7b
--- /dev/null
+++ b/src/lib/components/error.svelte
@@ -0,0 +1,38 @@
+<script lang="ts">
+  export let error: NetworkError & { stack?: string; status: number } = null;
+
+  let ENV =
+    typeof process !== 'undefined' && process.env && process.env.NODE_ENV;
+  let DEV = ENV !== 'production';
+
+  let status = 500;
+
+  if (error?.status) {
+    status = error.status;
+  }
+  if (error?.statusCode) {
+    status = error.statusCode;
+  }
+  $: currentLocation = window.location.toString();
+</script>
+
+<section aria-roledescription="error" class="text-center align-middle mt-32">
+  <h1 class="text-[12rem] font-semibold ">{status}</h1>
+  <p class="-mt-6 mb-5 text-lg">Uh oh. There's an error.</p>
+  <p class="text-lg">
+    <a
+      href={currentLocation}
+      target="_self"
+      class="underline-offset-2 underline">Try a refresh</a
+    >
+    or
+    <a href="https://temporal.io/slack" class="underline-offset-2 underline"
+      >jump on our Slack Channel</a
+    >
+  </p>
+</section>
+{#if DEV}
+  <pre class="trace">
+        {error?.stack ?? ''}
+    </pre>
+{/if}
diff --git a/src/lib/models/workflow-execution.ts b/src/lib/models/workflow-execution.ts
index 22c52728..a136495b 100644
--- a/src/lib/models/workflow-execution.ts
+++ b/src/lib/models/workflow-execution.ts
@@ -26,7 +26,7 @@ type WorkflowExecutionAPIResponse = Optional<
 >;
 
 export const toWorkflowExecution = (
-  response: WorkflowExecutionAPIResponse,
+  response?: WorkflowExecutionAPIResponse,
 ): WorkflowExecution => {
   const name = response.workflowExecutionInfo.type.name;
   const id = response.workflowExecutionInfo.execution.workflowId;
diff --git a/src/lib/stores/error.ts b/src/lib/stores/error.ts
new file mode 100644
index 00000000..d6d9c37d
--- /dev/null
+++ b/src/lib/stores/error.ts
@@ -0,0 +1,3 @@
+import { writable } from 'svelte/store';
+
+export const networkError = writable<NetworkError | null>(null);
diff --git a/src/lib/utilities/handle-error.ts b/src/lib/utilities/handle-error.ts
index 90ca29d7..58a93e0f 100644
--- a/src/lib/utilities/handle-error.ts
+++ b/src/lib/utilities/handle-error.ts
@@ -1,8 +1,15 @@
+import { networkError } from '$lib/stores/error';
 import { notifications } from '../stores/notifications';
+import { isNetworkError } from './is-network-error';
 
 // This will eventually be expanded on.
 export const handleError = (error: unknown): void => {
-  console.error(error);
+  if (isNetworkError(error)) {
+    notifications.add('error', `${error.statusCode} ${error.statusText}`);
+    // Re-throw error to prevent other code from attempting to render
+    networkError.set(error);
+    throw error;
+  }
 
   if (typeof error === 'string') {
     notifications.add('error', error);
diff --git a/src/lib/utilities/is-network-error.ts b/src/lib/utilities/is-network-error.ts
new file mode 100644
index 00000000..7c5f1c36
--- /dev/null
+++ b/src/lib/utilities/is-network-error.ts
@@ -0,0 +1,10 @@
+export function isNetworkError(
+  error: unknown | NetworkError,
+): error is NetworkError {
+  const networkErr = error as NetworkError;
+  return (
+    networkErr?.statusCode !== undefined &&
+    networkErr?.statusText !== undefined &&
+    networkErr?.response !== undefined
+  );
+}
diff --git a/src/lib/utilities/request-from-api.ts b/src/lib/utilities/request-from-api.ts
index fe3bac08..b5e0d888 100644
--- a/src/lib/utilities/request-from-api.ts
+++ b/src/lib/utilities/request-from-api.ts
@@ -77,7 +77,11 @@ export const requestFromAPI = async <T>(
       if (onError && isFunction(onError)) {
         onError({ status, statusText, body });
       } else {
-        throw new Error(`${status}: ${statusText}`);
+        throw {
+          statusCode: response.status,
+          statusText: response.statusText,
+          response,
+        } as NetworkError;
       }
     }
 
diff --git a/src/routes/__error.svelte b/src/routes/__error.svelte
new file mode 100644
index 00000000..4ca6de4c
--- /dev/null
+++ b/src/routes/__error.svelte
@@ -0,0 +1,28 @@
+<script context="module" lang="ts">
+  export function load({ error, status }) {
+    return {
+      props: {
+        error,
+        status,
+      },
+    };
+  }
+</script>
+
+<script lang="ts">
+  import Error from '$lib/components/error.svelte';
+  import { isNetworkError } from '$lib/utilities/is-network-error';
+
+  export let error;
+  export let status;
+  let requestFromAPIError;
+
+  try {
+    requestFromAPIError = JSON.parse(error.message);
+    if (isNetworkError(requestFromAPIError)) {
+      status = requestFromAPIError.statusCode;
+    }
+  } catch (e) {}
+</script>
+
+<Error {error} />
diff --git a/src/routes/__layout.svelte b/src/routes/__layout.svelte
index 077cceac..c0776d21 100644
--- a/src/routes/__layout.svelte
+++ b/src/routes/__layout.svelte
@@ -38,6 +38,7 @@
   import Header from './_header.svelte';
   import Notifications from '$lib/components/notifications.svelte';
   import Banner from '$lib/components/banner.svelte';
+  import { ErrorBoundary } from '$lib/components/error-boundary';
 
   export let namespaces: DescribeNamespaceResponse[];
 
@@ -62,7 +63,9 @@
   <Header />
   <section id="content" class="h-full mx-10 mb-10 mt-8">
     <div class="flex flex-col h-full gap-4">
-      <slot />
+      <ErrorBoundary onError={() => {}}>
+        <slot />
+      </ErrorBoundary>
     </div>
   </section>
 </main>


Test patch:
diff --git a/src/lib/utilities/is-network-error.test.ts b/src/lib/utilities/is-network-error.test.ts
new file mode 100644
index 00000000..a0c47c52
--- /dev/null
+++ b/src/lib/utilities/is-network-error.test.ts
@@ -0,0 +1,38 @@
+import { isNetworkError } from './is-network-error';
+
+const networkErr = {
+  statusCode: 200,
+  statusText: "Error'd",
+  response: {},
+};
+
+describe(isNetworkError, () => {
+  it('Should infer a networkErrorr error with the correct shape', () => {
+    try {
+      throw networkErr;
+    } catch (err) {
+      expect(isNetworkError(err)).toBeTruthy();
+    }
+  });
+  it('Should not infer a networkErrorr error with the incorrecrt shape', () => {
+    try {
+      throw { somethingElse: '' };
+    } catch (err) {
+      expect(isNetworkError(err)).toBeFalsy();
+    }
+  });
+  it("Should not infer a networkErrorr when it's a js error", () => {
+    try {
+      throw new Error('error');
+    } catch (err) {
+      expect(isNetworkError(err)).toBeFalsy();
+    }
+  });
+  it("Should not infer a networkErrorr when it's a promise", async () => {
+    try {
+      await Promise.reject();
+    } catch (err) {
+      expect(isNetworkError(err)).toBeFalsy();
+    }
+  });
+});