From f41d3f22c7cf745fda48fe1be542174609a977dc Mon Sep 17 00:00:00 2001
From: Matiss Janis Aboltins <matiss@mja.lv>
Date: Thu, 25 Apr 2024 19:31:04 +0100
Subject: [PATCH] :sparkles: improving error handling for lazy-loading (#2657)

---
 .../desktop-client/src/components/App.tsx     |  2 +-
 .../src/components/FatalError.tsx             | 52 ++++++++++++++-----
 .../src/components/common/Modal.tsx           | 18 ++++---
 .../components/gocardless/GoCardlessLink.tsx  |  2 +-
 .../src/components/util/LoadComponent.tsx     | 22 ++++++--
 packages/loot-core/src/shared/errors.ts       | 10 ++++
 upcoming-release-notes/2657.md                |  6 +++
 7 files changed, 85 insertions(+), 27 deletions(-)
 create mode 100644 upcoming-release-notes/2657.md

diff --git a/packages/desktop-client/src/components/App.tsx b/packages/desktop-client/src/components/App.tsx
index 85073350c..692f1986c 100644
--- a/packages/desktop-client/src/components/App.tsx
+++ b/packages/desktop-client/src/components/App.tsx
@@ -115,7 +115,7 @@ function ErrorFallback({ error }: FallbackProps) {
   return (
     <>
       <AppBackground />
-      <FatalError error={error} buttonText="Restart app" />
+      <FatalError error={error} />
     </>
   );
 }
diff --git a/packages/desktop-client/src/components/FatalError.tsx b/packages/desktop-client/src/components/FatalError.tsx
index e0122a79e..58e2ec114 100644
--- a/packages/desktop-client/src/components/FatalError.tsx
+++ b/packages/desktop-client/src/components/FatalError.tsx
@@ -1,5 +1,6 @@
-// @ts-strict-ignore
-import React, { useState } from 'react';
+import React, { useState, type ReactNode } from 'react';
+
+import { LazyLoadFailedError } from 'loot-core/src/shared/errors';
 
 import { Block } from './common/Block';
 import { Button } from './common/Button';
@@ -19,16 +20,14 @@ type AppError = Error & {
 };
 
 type FatalErrorProps = {
-  buttonText: string;
   error: Error | AppError;
 };
 
-type RenderSimpleProps = {
-  error: Error | AppError;
-};
+type RenderSimpleProps = FatalErrorProps;
 
 function RenderSimple({ error }: RenderSimpleProps) {
-  let msg;
+  let msg: ReactNode;
+
   if ('IDBFailure' in error && error.IDBFailure) {
     // IndexedDB wasn't able to open the database
     msg = (
@@ -93,6 +92,25 @@ function RenderSimple({ error }: RenderSimpleProps) {
   );
 }
 
+function RenderLazyLoadError() {
+  return (
+    <Stack
+      style={{
+        paddingBottom: 15,
+        lineHeight: '1.5em',
+        fontSize: 15,
+      }}
+    >
+      <Text>
+        There was a problem loading one of the chunks of the application. Please
+        reload the page and try again. If the issue persists - there might be an
+        issue with either your internet connection and/or the server where the
+        app is hosted.
+      </Text>
+    </Stack>
+  );
+}
+
 function RenderUIError() {
   return (
     <>
@@ -151,26 +169,32 @@ function SharedArrayBufferOverride() {
   );
 }
 
-export function FatalError({ buttonText, error }: FatalErrorProps) {
+export function FatalError({ error }: FatalErrorProps) {
   const [showError, setShowError] = useState(false);
 
   const showSimpleRender = 'type' in error && error.type === 'app-init-failure';
+  const isLazyLoadError = error instanceof LazyLoadFailedError;
 
   return (
-    <Modal isCurrent={true} CloseButton={undefined} title="Fatal Error">
+    <Modal isCurrent title={isLazyLoadError ? 'Loading Error' : 'Fatal Error'}>
       <View
         style={{
           maxWidth: 500,
         }}
       >
-        {showSimpleRender ? <RenderSimple error={error} /> : <RenderUIError />}
+        {isLazyLoadError ? (
+          <RenderLazyLoadError />
+        ) : showSimpleRender ? (
+          <RenderSimple error={error} />
+        ) : (
+          <RenderUIError />
+        )}
+
         <Paragraph>
-          <Button onClick={() => window.Actual?.relaunch()}>
-            {buttonText}
-          </Button>
+          <Button onClick={() => window.Actual?.relaunch()}>Restart app</Button>
         </Paragraph>
         <Paragraph isLast={true} style={{ fontSize: 11 }}>
-          <Link variant="text" onClick={() => setShowError(true)}>
+          <Link variant="text" onClick={() => setShowError(state => !state)}>
             Show Error
           </Link>
           {showError && (
diff --git a/packages/desktop-client/src/components/common/Modal.tsx b/packages/desktop-client/src/components/common/Modal.tsx
index 3fc6574b4..ddd498717 100644
--- a/packages/desktop-client/src/components/common/Modal.tsx
+++ b/packages/desktop-client/src/components/common/Modal.tsx
@@ -192,14 +192,16 @@ export const Modal = ({
               </View>
             )}
 
-            <View
-              style={{
-                position: 'absolute',
-                right: 0,
-              }}
-            >
-              <CloseButtonComponent onClick={onClose} />
-            </View>
+            {onClose && (
+              <View
+                style={{
+                  position: 'absolute',
+                  right: 0,
+                }}
+              >
+                <CloseButtonComponent onClick={onClose} />
+              </View>
+            )}
           </View>
         )}
         <View style={{ paddingTop: 0, flex: 1 }}>
diff --git a/packages/desktop-client/src/components/gocardless/GoCardlessLink.tsx b/packages/desktop-client/src/components/gocardless/GoCardlessLink.tsx
index 297763f0d..33d2fa81c 100644
--- a/packages/desktop-client/src/components/gocardless/GoCardlessLink.tsx
+++ b/packages/desktop-client/src/components/gocardless/GoCardlessLink.tsx
@@ -8,7 +8,7 @@ export function GoCardlessLink() {
   window.close();
 
   return (
-    <Modal isCurrent={true} CloseButton={undefined} title="Account sync">
+    <Modal isCurrent title="Account sync">
       {() => (
         <View style={{ maxWidth: 500 }}>
           <Paragraph>Please wait...</Paragraph>
diff --git a/packages/desktop-client/src/components/util/LoadComponent.tsx b/packages/desktop-client/src/components/util/LoadComponent.tsx
index 9d4275ade..3a180373c 100644
--- a/packages/desktop-client/src/components/util/LoadComponent.tsx
+++ b/packages/desktop-client/src/components/util/LoadComponent.tsx
@@ -2,6 +2,8 @@ import { type ComponentType, useEffect, useState } from 'react';
 
 import promiseRetry from 'promise-retry';
 
+import { LazyLoadFailedError } from 'loot-core/src/shared/errors';
+
 import { AnimatedLoading } from '../../icons/AnimatedLoading';
 import { theme, styles } from '../../style';
 import { Block } from '../common/Block';
@@ -19,22 +21,36 @@ export function LoadComponent<K extends string>(props: LoadComponentProps<K>) {
   return <LoadComponentInner key={props.name} {...props} />;
 }
 
+// Cache of the various modules so we would not need to
+// load the same thing multiple times.
+const localModuleCache = new Map();
+
 function LoadComponentInner<K extends string>({
   name,
   message,
   importer,
 }: LoadComponentProps<K>) {
-  const [Component, setComponent] = useState<ProplessComponent | null>(null);
+  const [Component, setComponent] = useState<ProplessComponent | null>(
+    localModuleCache.get(name) ?? null,
+  );
   const [failedToLoad, setFailedToLoad] = useState(false);
 
   useEffect(() => {
+    if (localModuleCache.has(name)) {
+      return;
+    }
+
     setFailedToLoad(false);
 
     // Load the module; if it fails - retry with exponential backoff
     promiseRetry(
       retry =>
         importer()
-          .then(module => setComponent(() => module[name]))
+          .then(module => {
+            const component = () => module[name];
+            localModuleCache.set(name, component);
+            setComponent(component);
+          })
           .catch(retry),
       {
         retries: 5,
@@ -45,7 +61,7 @@ function LoadComponentInner<K extends string>({
   }, [name, importer]);
 
   if (failedToLoad) {
-    throw new Error(`Failed loading the ${name} lazy module.`);
+    throw new LazyLoadFailedError(name);
   }
 
   if (!Component) {
diff --git a/packages/loot-core/src/shared/errors.ts b/packages/loot-core/src/shared/errors.ts
index c75424e12..e081481ff 100644
--- a/packages/loot-core/src/shared/errors.ts
+++ b/packages/loot-core/src/shared/errors.ts
@@ -91,3 +91,13 @@ export function getSyncError(error, id) {
     return `We had an unknown problem opening “${id}”.`;
   }
 }
+
+export class LazyLoadFailedError extends Error {
+  type = 'app-init-failure';
+  meta = {};
+
+  constructor(name: string) {
+    super(`Error: failed loading lazy-loaded module ${name}`);
+    this.meta = { name };
+  }
+}
diff --git a/upcoming-release-notes/2657.md b/upcoming-release-notes/2657.md
new file mode 100644
index 000000000..0c009975d
--- /dev/null
+++ b/upcoming-release-notes/2657.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [MatissJanis]
+---
+
+Add custom error message if lazy-loading a module fails.
-- 
GitLab