From 7da79308e201ae1f043b972a5651df96c25010ab Mon Sep 17 00:00:00 2001 From: bobbykolev Date: Wed, 20 Nov 2024 12:47:53 +0200 Subject: [PATCH 1/5] implement a globl lazyLoading error handling --- src/core/i18n/en/translation.en.json | 3 +- src/core/lazyLoading/GlobalErrorContext.tsx | 38 +++++++++++ src/core/lazyLoading/GlobalErrorDialog.tsx | 45 +++++++++++++ .../lazyLoading/lazyWithGlobalErrorHandler.ts | 25 ++++++++ src/main/routing/TopLevelRoutes.tsx | 31 +++++---- src/main/topLevelPages/Home/HomePage.tsx | 3 +- .../topLevelPages/myDashboard/MyDashboard.tsx | 7 ++- .../MyDashboardWithMemberships.tsx | 16 +++-- .../MyDashboardWithoutMemberships.tsx | 6 +- src/root.tsx | 63 ++++++++++--------- 10 files changed, 182 insertions(+), 55 deletions(-) create mode 100644 src/core/lazyLoading/GlobalErrorContext.tsx create mode 100644 src/core/lazyLoading/GlobalErrorDialog.tsx create mode 100644 src/core/lazyLoading/lazyWithGlobalErrorHandler.ts diff --git a/src/core/i18n/en/translation.en.json b/src/core/i18n/en/translation.en.json index 28283b217b..8c86af7326 100644 --- a/src/core/i18n/en/translation.en.json +++ b/src/core/i18n/en/translation.en.json @@ -2556,7 +2556,8 @@ "line3": "If the error persists please contact support.", "buttons": { "reload": "Reload" - } + }, + "dynamicError": "network error or a newer version available. Please reload the page." }, "four-ou-four": { "title": "Something went wrong...", diff --git a/src/core/lazyLoading/GlobalErrorContext.tsx b/src/core/lazyLoading/GlobalErrorContext.tsx new file mode 100644 index 0000000000..e9b7276b49 --- /dev/null +++ b/src/core/lazyLoading/GlobalErrorContext.tsx @@ -0,0 +1,38 @@ +import React, { createContext, useContext, useState, ReactNode } from 'react'; + +type GlobalErrorContextType = { + error: Error | null; + setError: (error: Error | null) => void; +}; + +// Global function to set error (to be initialized by provider) +let setGlobalError: ((error: Error | null) => void) | null = null; + +export const useGlobalError = (): GlobalErrorContextType => { + const context = useContext(GlobalErrorContext); + if (!context) { + throw new Error('useGlobalError must be used within a GlobalErrorProvider'); + } + return context; +}; + +const GlobalErrorContext = createContext(undefined); + +// GlobalErrorProvider but used only for LazyLoading ATM +// see SentryErrorBoundary for global error handling +export const GlobalErrorProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [error, setError] = useState(null); + + // Set the global error function during initialization + setGlobalError = setError; + + return {children}; +}; + +// the global error setter +export const getGlobalErrorSetter = (): ((error: Error | null) => void) => { + if (!setGlobalError) { + throw new Error('GlobalErrorProvider is not initialized.'); + } + return setGlobalError; +}; diff --git a/src/core/lazyLoading/GlobalErrorDialog.tsx b/src/core/lazyLoading/GlobalErrorDialog.tsx new file mode 100644 index 0000000000..0c47078888 --- /dev/null +++ b/src/core/lazyLoading/GlobalErrorDialog.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { Box, Button, Dialog, DialogContent } from '@mui/material'; +import { useGlobalError } from './GlobalErrorContext'; +import DialogHeader from '../ui/dialog/DialogHeader'; +import { BlockTitle } from '../ui/typography'; + +export const GlobalErrorDialog: React.FC = () => { + const { t } = useTranslation(); + const { error, setError } = useGlobalError(); + + if (!error) return null; + + return ( + setError(null)}> + setError(null)}> + {t('pages.error.title')} + + + + , + }} + /> + + + + + + + ); +}; diff --git a/src/core/lazyLoading/lazyWithGlobalErrorHandler.ts b/src/core/lazyLoading/lazyWithGlobalErrorHandler.ts new file mode 100644 index 0000000000..76c4a862c9 --- /dev/null +++ b/src/core/lazyLoading/lazyWithGlobalErrorHandler.ts @@ -0,0 +1,25 @@ +import React from 'react'; +import { getGlobalErrorSetter } from './GlobalErrorContext'; + +type ImportFunc = () => Promise<{ default: React.ComponentType }>; + +export const lazyWithGlobalErrorHandler = ( + importFunc: ImportFunc +): React.LazyExoticComponent> => { + return React.lazy(async () => { + try { + return await importFunc(); + } catch (error) { + const setError = getGlobalErrorSetter(); + setError(error as Error); + + // it looks like this error is already logged by the useErrorLoggerLink (network error) + + // Instead of throwing, return a fallback component to prevent + // catching it in the ErrorBoundary + return { + default: () => null, + }; + } + }); +}; diff --git a/src/main/routing/TopLevelRoutes.tsx b/src/main/routing/TopLevelRoutes.tsx index cf625ce816..cc89c82f2e 100644 --- a/src/main/routing/TopLevelRoutes.tsx +++ b/src/main/routing/TopLevelRoutes.tsx @@ -18,22 +18,27 @@ import { EntityPageLayoutHolder, NotFoundPageLayout, RenderPoint } from '../../d import RedirectToWelcomeSite from '../../domain/platform/routes/RedirectToWelcomeSite'; import { TopLevelRoutePath } from './TopLevelRoutePath'; import Loading from '../../core/ui/loading/Loading'; +import { lazyWithGlobalErrorHandler } from '../../core/lazyLoading/lazyWithGlobalErrorHandler'; -const DocumentationPage = React.lazy(() => import('../../domain/documentation/DocumentationPage')); -const SpaceExplorerPage = React.lazy(() => import('../topLevelPages/topLevelSpaces/SpaceExplorerPage')); -const InnovationLibraryPage = React.lazy(() => import('../topLevelPages/InnovationLibraryPage/InnovationLibraryPage')); -const ContributorsPage = React.lazy(() => import('../../domain/community/user/ContributorsPage')); -const AdminRoute = React.lazy(() => import('../../domain/platform/admin/routing/AdminRoute')); -const UserRoute = React.lazy(() => import('../../domain/community/user/routing/UserRoute')); -const OrganizationRoute = React.lazy( +const DocumentationPage = lazyWithGlobalErrorHandler(() => import('../../domain/documentation/DocumentationPage')); +const SpaceExplorerPage = lazyWithGlobalErrorHandler(() => import('../topLevelPages/topLevelSpaces/SpaceExplorerPage')); +const InnovationLibraryPage = lazyWithGlobalErrorHandler( + () => import('../topLevelPages/InnovationLibraryPage/InnovationLibraryPage') +); +const ContributorsPage = lazyWithGlobalErrorHandler(() => import('../../domain/community/user/ContributorsPage')); +const AdminRoute = lazyWithGlobalErrorHandler(() => import('../../domain/platform/admin/routing/AdminRoute')); +const UserRoute = lazyWithGlobalErrorHandler(() => import('../../domain/community/user/routing/UserRoute')); +const OrganizationRoute = lazyWithGlobalErrorHandler( () => import('../../domain/community/contributor/organization/routing/OrganizationRoute') ); -const VCRoute = React.lazy(() => import('../../domain/community/virtualContributor/VCRoute')); -const ForumRoute = React.lazy(() => import('../../domain/communication/discussion/routing/ForumRoute')); -const InnovationPackRoute = React.lazy(() => import('../../domain/InnovationPack/InnovationPackRoute')); -const ProfileRoute = React.lazy(() => import('../../domain/community/profile/routing/ProfileRoute')); -const CreateSpaceDialog = React.lazy(() => import('../../domain/journey/space/createSpace/CreateSpaceDialog')); -const SpaceRoute = React.lazy(() => import('../../domain/journey/space/routing/SpaceRoute')); +const VCRoute = lazyWithGlobalErrorHandler(() => import('../../domain/community/virtualContributor/VCRoute')); +const ForumRoute = lazyWithGlobalErrorHandler(() => import('../../domain/communication/discussion/routing/ForumRoute')); +const InnovationPackRoute = lazyWithGlobalErrorHandler(() => import('../../domain/InnovationPack/InnovationPackRoute')); +const ProfileRoute = lazyWithGlobalErrorHandler(() => import('../../domain/community/profile/routing/ProfileRoute')); +const CreateSpaceDialog = lazyWithGlobalErrorHandler( + () => import('../../domain/journey/space/createSpace/CreateSpaceDialog') +); +const SpaceRoute = lazyWithGlobalErrorHandler(() => import('../../domain/journey/space/routing/SpaceRoute')); export const TopLevelRoutes = () => { useRedirectToIdentityDomain(); diff --git a/src/main/topLevelPages/Home/HomePage.tsx b/src/main/topLevelPages/Home/HomePage.tsx index 163156a2be..f44eaaecda 100644 --- a/src/main/topLevelPages/Home/HomePage.tsx +++ b/src/main/topLevelPages/Home/HomePage.tsx @@ -4,8 +4,9 @@ import InnovationHubHomePage from '../../../domain/innovationHub/InnovationHubHo import Loading from '../../../core/ui/loading/Loading'; import useInnovationHub from '../../../domain/innovationHub/useInnovationHub/useInnovationHub'; import PageContent from '../../../core/ui/content/PageContent'; +import { lazyWithGlobalErrorHandler } from '../../../core/lazyLoading/lazyWithGlobalErrorHandler'; -const MyDashboard = React.lazy(() => import('../myDashboard/MyDashboard')); +const MyDashboard = lazyWithGlobalErrorHandler(() => import('../myDashboard/MyDashboard')); const HomePage = () => { const { innovationHub, innovationHubLoading } = useInnovationHub(); diff --git a/src/main/topLevelPages/myDashboard/MyDashboard.tsx b/src/main/topLevelPages/myDashboard/MyDashboard.tsx index 8994605b7b..448cde6ba0 100644 --- a/src/main/topLevelPages/myDashboard/MyDashboard.tsx +++ b/src/main/topLevelPages/myDashboard/MyDashboard.tsx @@ -3,10 +3,11 @@ import { useLatestContributionsSpacesFlatQuery } from '../../../core/apollo/gene import Loading from '../../../core/ui/loading/Loading'; import { useAuthenticationContext } from '../../../core/auth/authentication/hooks/useAuthenticationContext'; import { DashboardProvider } from './DashboardContext'; +import { lazyWithGlobalErrorHandler } from '../../../core/lazyLoading/lazyWithGlobalErrorHandler'; -const MyDashboardUnauthenticated = React.lazy(() => import('./MyDashboardUnauthenticated')); -const MyDashboardWithMemberships = React.lazy(() => import('./MyDashboardWithMemberships')); -const MyDashboardWithoutMemberships = React.lazy(() => import('./MyDashboardWithoutMemberships')); +const MyDashboardUnauthenticated = lazyWithGlobalErrorHandler(() => import('./MyDashboardUnauthenticated')); +const MyDashboardWithMemberships = lazyWithGlobalErrorHandler(() => import('./MyDashboardWithMemberships')); +const MyDashboardWithoutMemberships = lazyWithGlobalErrorHandler(() => import('./MyDashboardWithoutMemberships')); export const MyDashboard = () => { const { isAuthenticated, loading: isLoadingAuthentication } = useAuthenticationContext(); diff --git a/src/main/topLevelPages/myDashboard/MyDashboardWithMemberships.tsx b/src/main/topLevelPages/myDashboard/MyDashboardWithMemberships.tsx index c1b81500fa..838aeff94b 100644 --- a/src/main/topLevelPages/myDashboard/MyDashboardWithMemberships.tsx +++ b/src/main/topLevelPages/myDashboard/MyDashboardWithMemberships.tsx @@ -9,10 +9,14 @@ import ContentColumn from '../../../core/ui/content/ContentColumn'; import { useDashboardContext } from './DashboardContext'; import MyResources from './myResources/MyResources'; import { Theme, useMediaQuery } from '@mui/material'; +import { lazyWithGlobalErrorHandler } from '../../../core/lazyLoading/lazyWithGlobalErrorHandler'; +import Loading from '../../../core/ui/loading/Loading'; -const DashboardDialogs = React.lazy(() => import('./DashboardDialogs/DashboardDialogs')); -const DashboardActivity = React.lazy(() => import('./DashboardWithMemberships/DashboardActivity')); -const DashboardSpaces = React.lazy(() => import('./DashboardWithMemberships/DashboardSpaces/DashboardSpaces')); +const DashboardDialogs = lazyWithGlobalErrorHandler(() => import('./DashboardDialogs/DashboardDialogs')); +const DashboardActivity = lazyWithGlobalErrorHandler(() => import('./DashboardWithMemberships/DashboardActivity')); +const DashboardSpaces = lazyWithGlobalErrorHandler( + () => import('./DashboardWithMemberships/DashboardSpaces/DashboardSpaces') +); const MyDashboardWithMemberships = () => { const { activityEnabled } = useDashboardContext(); @@ -36,17 +40,17 @@ const MyDashboardWithMemberships = () => { {data?.platform.latestReleaseDiscussion && } {!activityEnabled && ( - + }> )} {activityEnabled && ( - + }> )} - + }> diff --git a/src/main/topLevelPages/myDashboard/MyDashboardWithoutMemberships.tsx b/src/main/topLevelPages/myDashboard/MyDashboardWithoutMemberships.tsx index 8df0816921..32e49197ec 100644 --- a/src/main/topLevelPages/myDashboard/MyDashboardWithoutMemberships.tsx +++ b/src/main/topLevelPages/myDashboard/MyDashboardWithoutMemberships.tsx @@ -14,8 +14,10 @@ import { InvitationsBlock } from './InvitationsBlock/InvitationsBlock'; import { SpaceIcon } from '../../../domain/journey/space/icon/SpaceIcon'; import RouterLink from '../../../core/ui/link/RouterLink'; import { useCreateSpaceLink } from './useCreateSpaceLink/useCreateSpaceLink'; +import { lazyWithGlobalErrorHandler } from '../../../core/lazyLoading/lazyWithGlobalErrorHandler'; +import Loading from '../../../core/ui/loading/Loading'; -const DashboardDialogs = React.lazy(() => import('./DashboardDialogs/DashboardDialogs')); +const DashboardDialogs = lazyWithGlobalErrorHandler(() => import('./DashboardDialogs/DashboardDialogs')); const MyDashboardWithoutMemberships = () => { const { t } = useTranslation(); @@ -57,7 +59,7 @@ const MyDashboardWithoutMemberships = () => { {t('buttons.createOwnSpace')} - + }> diff --git a/src/root.tsx b/src/root.tsx index 29eca77ff6..19f5f22308 100644 --- a/src/root.tsx +++ b/src/root.tsx @@ -24,6 +24,8 @@ import { PendingMembershipsDialogProvider } from './domain/community/pendingMemb import { NotFoundErrorBoundary } from './core/notFound/NotFoundErrorBoundary'; import { Error404 } from './core/pages/Errors/Error404'; import TopLevelLayout from './main/ui/layout/TopLevelLayout'; +import { GlobalErrorProvider } from './core/lazyLoading/GlobalErrorContext'; +import { GlobalErrorDialog } from './core/lazyLoading/GlobalErrorDialog'; const useGlobalStyles = makeStyles(theme => ({ '@global': { @@ -75,35 +77,38 @@ const Root: FC = () => { - - - - - - - - - - - - - - - } - > - - - - - - - - - - - + + + + + + + + + + + + + + + + } + > + + + + + + + + + + + + + From 0a1e902d890c486e3e91f55716902324f50959ed Mon Sep 17 00:00:00 2001 From: Carlos Cano Date: Thu, 21 Nov 2024 16:02:11 +0200 Subject: [PATCH 2/5] Apply lazyload error handler to whiteboards --- .../lazyLoading/lazyWithGlobalErrorHandler.ts | 18 +++++++ .../SingleUserWhiteboardDialog.tsx | 13 +++-- .../WhiteboardPreviewImages.ts | 8 +++- .../whiteboard/utils/mergeWhiteboard.ts | 19 ++++++-- .../CollaborativeExcalidrawWrapper.tsx | 3 +- .../excalidraw/ExcalidrawWrapper.tsx | 3 +- .../whiteboard/excalidraw/collab/Collab.ts | 48 ++++++++++++------- .../excalidraw/collab/data/index.ts | 4 +- 8 files changed, 85 insertions(+), 31 deletions(-) diff --git a/src/core/lazyLoading/lazyWithGlobalErrorHandler.ts b/src/core/lazyLoading/lazyWithGlobalErrorHandler.ts index 76c4a862c9..f4de72f105 100644 --- a/src/core/lazyLoading/lazyWithGlobalErrorHandler.ts +++ b/src/core/lazyLoading/lazyWithGlobalErrorHandler.ts @@ -23,3 +23,21 @@ export const lazyWithGlobalErrorHandler = ( } }); }; + +class ContentUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = 'ContentUnavailableError'; + Object.setPrototypeOf(this, ContentUnavailableError.prototype); + } +} + +export const lazyImportWithErrorHandler = async (importFunc: () => Promise): Promise => { + try { + return await importFunc(); + } catch (error) { + const setError = getGlobalErrorSetter(); + setError(error as Error); + throw new ContentUnavailableError((error as Error)?.message); + } +}; diff --git a/src/domain/collaboration/whiteboard/WhiteboardDialog/SingleUserWhiteboardDialog.tsx b/src/domain/collaboration/whiteboard/WhiteboardDialog/SingleUserWhiteboardDialog.tsx index c65c7e63ab..8203840882 100644 --- a/src/domain/collaboration/whiteboard/WhiteboardDialog/SingleUserWhiteboardDialog.tsx +++ b/src/domain/collaboration/whiteboard/WhiteboardDialog/SingleUserWhiteboardDialog.tsx @@ -31,6 +31,12 @@ import useWhiteboardFilesManager from '@/domain/common/whiteboard/excalidraw/use import { WhiteboardTemplateContent } from '@/domain/templates/models/WhiteboardTemplate'; import { WhiteboardDetails } from './WhiteboardDialog'; import { Identifiable } from '@/core/utils/Identifiable'; +import type { serializeAsJSON as ExcalidrawSerializeAsJSON } from '@alkemio/excalidraw'; +import { lazyImportWithErrorHandler } from '@/core/lazyLoading/lazyWithGlobalErrorHandler'; + +type ExcalidrawUtils = { + serializeAsJSON: typeof ExcalidrawSerializeAsJSON; +}; export interface WhiteboardWithContent extends WhiteboardDetails { content: string; @@ -116,8 +122,7 @@ const SingleUserWhiteboardDialog = ({ entities, actions, options, state }: Singl if (!state) { return; } - - const { serializeAsJSON } = await import('@alkemio/excalidraw'); + const { serializeAsJSON } = await lazyImportWithErrorHandler(() => import('@alkemio/excalidraw')); const { appState, elements, files } = await filesManager.convertLocalFilesToRemoteInWhiteboard(state); @@ -153,7 +158,9 @@ const SingleUserWhiteboardDialog = ({ entities, actions, options, state }: Singl const onClose = async (event: React.MouseEvent) => { if (excalidrawAPI && options.canEdit) { - const { serializeAsJSON } = await import('@alkemio/excalidraw'); + const { serializeAsJSON } = await lazyImportWithErrorHandler( + () => import('@alkemio/excalidraw') + ); const elements = excalidrawAPI.getSceneElements(); const appState = excalidrawAPI.getAppState(); diff --git a/src/domain/collaboration/whiteboard/WhiteboardPreviewImages/WhiteboardPreviewImages.ts b/src/domain/collaboration/whiteboard/WhiteboardPreviewImages/WhiteboardPreviewImages.ts index 5e5386425a..0a1803b8e9 100644 --- a/src/domain/collaboration/whiteboard/WhiteboardPreviewImages/WhiteboardPreviewImages.ts +++ b/src/domain/collaboration/whiteboard/WhiteboardPreviewImages/WhiteboardPreviewImages.ts @@ -3,9 +3,15 @@ import getWhiteboardPreviewDimensions from './getWhiteboardPreviewDimensions'; import { VisualType } from '@/core/apollo/generated/graphql-schema'; import { useUploadVisualMutation } from '@/core/apollo/generated/apollo-hooks'; import { BannerDimensions, BannerNarrowDimensions } from './WhiteboardDimensions'; +import type { exportToBlob as ExcalidrawExportToBlob } from '@alkemio/excalidraw'; +import { lazyImportWithErrorHandler } from '@/core/lazyLoading/lazyWithGlobalErrorHandler'; type RelevantExcalidrawState = Pick; +type ExcalidrawUtils = { + exportToBlob: typeof ExcalidrawExportToBlob; +}; + export interface PreviewImageDimensions { maxWidth: number; maxHeight: number; @@ -43,7 +49,7 @@ export const generateWhiteboardPreviewImages = async (() => import('@alkemio/excalidraw')); previewImages.push({ visualType: VisualType.Banner, diff --git a/src/domain/collaboration/whiteboard/utils/mergeWhiteboard.ts b/src/domain/collaboration/whiteboard/utils/mergeWhiteboard.ts index d12477b149..c022591b69 100644 --- a/src/domain/collaboration/whiteboard/utils/mergeWhiteboard.ts +++ b/src/domain/collaboration/whiteboard/utils/mergeWhiteboard.ts @@ -1,12 +1,21 @@ import type { ExcalidrawElement } from '@alkemio/excalidraw/dist/excalidraw/element/types'; import type { BinaryFileData, ExcalidrawImperativeAPI } from '@alkemio/excalidraw/dist/excalidraw/types'; import { v4 as uuidv4 } from 'uuid'; -import { StoreAction } from '@alkemio/excalidraw'; +import type { + StoreAction as ExcalidrawStoreAction, + getSceneVersion as ExcalidrawGetSceneVersion, +} from '@alkemio/excalidraw'; +import { lazyImportWithErrorHandler } from '@/core/lazyLoading/lazyWithGlobalErrorHandler'; const ANIMATION_SPEED = 2000; const ANIMATION_ZOOM_FACTOR = 0.75; type ExcalidrawElementWithContainerId = ExcalidrawElement & { containerId: string | null }; +type ExcalidrawUtils = { + StoreAction: typeof ExcalidrawStoreAction; + getSceneVersion: typeof ExcalidrawGetSceneVersion; +}; + class WhiteboardMergeError extends Error {} interface WhiteboardLike { @@ -122,9 +131,9 @@ const displaceElements = (displacement: { x: number; y: number }) => (element: E }); const mergeWhiteboard = async (whiteboardApi: ExcalidrawImperativeAPI, whiteboardContent: string) => { - const excalidrawUtils: { - getSceneVersion: (elements: readonly ExcalidrawElement[]) => number; - } = await import('@alkemio/excalidraw'); + const { getSceneVersion, StoreAction } = await lazyImportWithErrorHandler( + () => import('@alkemio/excalidraw') + ); let parsedWhiteboard: unknown; try { @@ -147,7 +156,7 @@ const mergeWhiteboard = async (whiteboardApi: ExcalidrawImperativeAPI, whiteboar } const currentElements = whiteboardApi.getSceneElementsIncludingDeleted(); - const sceneVersion = excalidrawUtils.getSceneVersion(whiteboardApi.getSceneElementsIncludingDeleted()); + const sceneVersion = getSceneVersion(whiteboardApi.getSceneElementsIncludingDeleted()); const currentElementsBBox = getBoundingBox(currentElements); const insertedWhiteboardBBox = getBoundingBox(parsedWhiteboard.elements); diff --git a/src/domain/common/whiteboard/excalidraw/CollaborativeExcalidrawWrapper.tsx b/src/domain/common/whiteboard/excalidraw/CollaborativeExcalidrawWrapper.tsx index 5ebfd7ccf3..0b13a33342 100644 --- a/src/domain/common/whiteboard/excalidraw/CollaborativeExcalidrawWrapper.tsx +++ b/src/domain/common/whiteboard/excalidraw/CollaborativeExcalidrawWrapper.tsx @@ -27,11 +27,12 @@ import { useTick } from '@/core/utils/time/tick'; import useWhiteboardDefaults from './useWhiteboardDefaults'; import Loading from '@/core/ui/loading/Loading'; import { Identifiable } from '@/core/utils/Identifiable'; +import { lazyWithGlobalErrorHandler } from '@/core/lazyLoading/lazyWithGlobalErrorHandler'; const FILE_IMPORT_ENABLED = true; const SAVE_FILE_TO_DISK = true; -const Excalidraw = React.lazy(async () => { +const Excalidraw = lazyWithGlobalErrorHandler(async () => { const { Excalidraw } = await import('@alkemio/excalidraw'); await import('@alkemio/excalidraw/index.css'); return { default: Excalidraw }; diff --git a/src/domain/common/whiteboard/excalidraw/ExcalidrawWrapper.tsx b/src/domain/common/whiteboard/excalidraw/ExcalidrawWrapper.tsx index 3f2df862ab..a2d4e4ba1e 100644 --- a/src/domain/common/whiteboard/excalidraw/ExcalidrawWrapper.tsx +++ b/src/domain/common/whiteboard/excalidraw/ExcalidrawWrapper.tsx @@ -16,6 +16,7 @@ import EmptyWhiteboard from '../EmptyWhiteboard'; import { WhiteboardFilesManager } from './useWhiteboardFilesManager'; import useWhiteboardDefaults from './useWhiteboardDefaults'; import Loading from '@/core/ui/loading/Loading'; +import { lazyWithGlobalErrorHandler } from '@/core/lazyLoading/lazyWithGlobalErrorHandler'; const useActorWhiteboardStyles = makeStyles(theme => ({ container: { @@ -55,7 +56,7 @@ type RefreshWhiteboardStateParam = Parameters { +const Excalidraw = lazyWithGlobalErrorHandler(async () => { const { Excalidraw } = await import('@alkemio/excalidraw'); await import('@alkemio/excalidraw/index.css'); return { default: Excalidraw }; diff --git a/src/domain/common/whiteboard/excalidraw/collab/Collab.ts b/src/domain/common/whiteboard/excalidraw/collab/Collab.ts index 329a65f9cf..8776a696ca 100644 --- a/src/domain/common/whiteboard/excalidraw/collab/Collab.ts +++ b/src/domain/common/whiteboard/excalidraw/collab/Collab.ts @@ -6,8 +6,13 @@ import type { SocketId, } from '@alkemio/excalidraw/dist/excalidraw/types'; import type { ExcalidrawElement, OrderedExcalidrawElement } from '@alkemio/excalidraw/dist/excalidraw/element/types'; -import { newElementWith } from '@alkemio/excalidraw/dist/excalidraw/element/mutateElement'; -import { hashElementsVersion, reconcileElements, restoreElements, StoreAction } from '@alkemio/excalidraw'; +import { + hashElementsVersion as ExcalidrawHashElementsVersion, + reconcileElements as ExcalidrawReconcileElements, + restoreElements as ExcalidrawRestoreElements, + StoreAction as ExcalidrawStoreAction, + newElementWith as ExcalidrawNewElementWith, +} from '@alkemio/excalidraw'; import { ACTIVE_THRESHOLD, CollaboratorModeEvent, @@ -26,7 +31,8 @@ import { ReconciledExcalidrawElement, RemoteExcalidrawElement, } from '@alkemio/excalidraw/dist/excalidraw/data/reconcile'; -import { Mutable } from '@alkemio/excalidraw/dist/excalidraw/utility-types'; +import type { Mutable } from '@alkemio/excalidraw/dist/excalidraw/utility-types'; +import { lazyImportWithErrorHandler } from '@/core/lazyLoading/lazyWithGlobalErrorHandler'; type CollabState = { errorMessage: string; @@ -51,6 +57,15 @@ type IncomingClientBroadcastData = { }; }; +// List of used functions from Excalidraw that will be lazy loaded +type ExcalidrawUtils = { + hashElementsVersion: typeof ExcalidrawHashElementsVersion; + reconcileElements: typeof ExcalidrawReconcileElements; + restoreElements: typeof ExcalidrawRestoreElements; + StoreAction: typeof ExcalidrawStoreAction; + newElementWith: typeof ExcalidrawNewElementWith; +}; + class Collab { portal: Portal; state: CollabState; @@ -65,11 +80,7 @@ class Collab { private onCloseConnection: () => void; private onCollaboratorModeChange: (event: CollaboratorModeEvent) => void; private onSceneInitChange: (initialized: boolean) => void; - private excalidrawUtils: Promise<{ - hashElementsVersion: typeof hashElementsVersion; - newElementWith: typeof newElementWith; - restoreElements: typeof restoreElements; - }>; + private excalidrawUtils: Promise; constructor(props: CollabProps) { this.state = { @@ -89,7 +100,7 @@ class Collab { this.filesManager = props.filesManager; this.onCollaboratorModeChange = props.onCollaboratorModeChange; this.onSceneInitChange = props.onSceneInitChange; - this.excalidrawUtils = import('@alkemio/excalidraw'); + this.excalidrawUtils = lazyImportWithErrorHandler(() => import('@alkemio/excalidraw')); } init() { @@ -124,12 +135,11 @@ class Collab { }; stopCollaboration = async () => { - this.queueBroadcastAllElements.cancel(); + const { StoreAction, newElementWith } = await this.excalidrawUtils; + this.queueBroadcastAllElements.cancel(); this.destroySocketClient(); - const { newElementWith } = await this.excalidrawUtils; - const elements = this.excalidrawAPI.getSceneElementsIncludingDeleted().map(element => { if (isImageElement(element) && element.status === 'saved') { return newElementWith(element, { status: 'pending' }); @@ -176,7 +186,7 @@ class Collab { 'scene-init': async (payload: { elements: readonly ExcalidrawElement[]; files: BinaryFilesWithUrl }) => { if (!this.portal.socketInitialized) { this.initializeRoom({ fetchScene: false }); - this.handleRemoteSceneUpdate( + await this.handleRemoteSceneUpdate( await this.reconcileElementsAndLoadFiles(payload.elements, payload.files), { init: true, @@ -217,7 +227,9 @@ class Collab { } else if (isSceneUpdatePayload(data)) { const remoteElements = data.payload.elements as RemoteExcalidrawElement[]; const remoteFiles = data.payload.files; - this.handleRemoteSceneUpdate(await this.reconcileElementsAndLoadFiles(remoteElements, remoteFiles)); + await this.handleRemoteSceneUpdate( + await this.reconcileElementsAndLoadFiles(remoteElements, remoteFiles) + ); } }, 'collaborator-mode': event => { @@ -298,7 +310,7 @@ class Collab { const localElements = this.getSceneElementsIncludingDeleted(); const appState = this.excalidrawAPI.getAppState(); - const { restoreElements } = await this.excalidrawUtils; + const { restoreElements, hashElementsVersion, reconcileElements } = await this.excalidrawUtils; const restoredRemoteElements = restoreElements(remoteElements, null); @@ -311,8 +323,6 @@ class Collab { // Download the files that this instance is missing: await this.filesManager.loadFiles({ files: remoteFiles }); - const { hashElementsVersion } = await this.excalidrawUtils; - // Avoid broadcasting to the rest of the collaborators the scene // we just received! // Note: this needs to be set before updating the scene as it @@ -322,10 +332,12 @@ class Collab { return reconciledElements; }; - private handleRemoteSceneUpdate = ( + private handleRemoteSceneUpdate = async ( elements: ReconciledExcalidrawElement[], { init = false }: { init?: boolean } = {} ) => { + const { StoreAction } = await this.excalidrawUtils; + this.excalidrawAPI.updateScene({ elements, storeAction: init ? StoreAction.CAPTURE : StoreAction.NONE, diff --git a/src/domain/common/whiteboard/excalidraw/collab/data/index.ts b/src/domain/common/whiteboard/excalidraw/collab/data/index.ts index ffbad1cde2..80cd7d65e8 100644 --- a/src/domain/common/whiteboard/excalidraw/collab/data/index.ts +++ b/src/domain/common/whiteboard/excalidraw/collab/data/index.ts @@ -3,8 +3,8 @@ import type { AppState, CollaboratorPointer, SocketId, UserIdleState } from '@al import { DELETED_ELEMENT_TIMEOUT, WS_SCENE_EVENT_TYPES } from '../excalidrawAppConstants'; import { env } from '@/main/env'; import { BinaryFilesWithUrl } from '@/domain/common/whiteboard/excalidraw/useWhiteboardFilesManager'; -import { MakeBrand } from '@alkemio/excalidraw/dist/excalidraw/utility-types'; -import { isInvisiblySmallElement } from '@alkemio/excalidraw'; +import type { MakeBrand } from '@alkemio/excalidraw/dist/excalidraw/utility-types'; +import { isInvisiblySmallElement } from '@alkemio/excalidraw'; // TODO: make lazy - not possible to keep the type assertion `element is SyncableExcalidrawElement` if isInvisiblySmallElement is asynchrounously loaded export type SyncableExcalidrawElement = OrderedExcalidrawElement & MakeBrand<'SyncableExcalidrawElement'>; From 4e24aa9378f63d76281333586a30eac369446bdb Mon Sep 17 00:00:00 2001 From: Carlos Cano Date: Thu, 21 Nov 2024 16:05:34 +0200 Subject: [PATCH 3/5] Rewording --- src/core/i18n/en/translation.en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/i18n/en/translation.en.json b/src/core/i18n/en/translation.en.json index 2240d43bc9..8dcfee484f 100644 --- a/src/core/i18n/en/translation.en.json +++ b/src/core/i18n/en/translation.en.json @@ -2557,7 +2557,7 @@ "buttons": { "reload": "Reload" }, - "dynamicError": "network error or a newer version available. Please reload the page." + "dynamicError": "Network error occurred. Please try reloading the page." }, "four-ou-four": { "title": "Something went wrong...", From 5d10b628b15a95d3e7226b5e1663e59109f38d52 Mon Sep 17 00:00:00 2001 From: Carlos Cano Date: Thu, 21 Nov 2024 16:09:31 +0200 Subject: [PATCH 4/5] Make the error.message detector more accurate --- src/core/lazyLoading/GlobalErrorDialog.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/lazyLoading/GlobalErrorDialog.tsx b/src/core/lazyLoading/GlobalErrorDialog.tsx index 0c47078888..35ad1a41f3 100644 --- a/src/core/lazyLoading/GlobalErrorDialog.tsx +++ b/src/core/lazyLoading/GlobalErrorDialog.tsx @@ -21,7 +21,9 @@ export const GlobalErrorDialog: React.FC = () => { , From 09038abbd02c7d3a226448ca36957121aba6f0a0 Mon Sep 17 00:00:00 2001 From: Carlos Cano Date: Thu, 21 Nov 2024 16:40:48 +0200 Subject: [PATCH 5/5] Translations --- src/core/i18n/en/translation.en.json | 5 +++- src/core/lazyLoading/GlobalErrorDialog.tsx | 13 +++++++-- .../lazyLoading/lazyWithGlobalErrorHandler.ts | 28 +++++++++++-------- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/core/i18n/en/translation.en.json b/src/core/i18n/en/translation.en.json index 8dcfee484f..68c64d358a 100644 --- a/src/core/i18n/en/translation.en.json +++ b/src/core/i18n/en/translation.en.json @@ -2557,7 +2557,10 @@ "buttons": { "reload": "Reload" }, - "dynamicError": "Network error occurred. Please try reloading the page." + "errors": { + "LazyLoadError": "Network error occurred. Please try reloading the page.", + "unknown": "An unknown error occurred. Please try reloading the page." + } }, "four-ou-four": { "title": "Something went wrong...", diff --git a/src/core/lazyLoading/GlobalErrorDialog.tsx b/src/core/lazyLoading/GlobalErrorDialog.tsx index 35ad1a41f3..7007a67f7c 100644 --- a/src/core/lazyLoading/GlobalErrorDialog.tsx +++ b/src/core/lazyLoading/GlobalErrorDialog.tsx @@ -4,6 +4,15 @@ import { Box, Button, Dialog, DialogContent } from '@mui/material'; import { useGlobalError } from './GlobalErrorContext'; import DialogHeader from '../ui/dialog/DialogHeader'; import { BlockTitle } from '../ui/typography'; +import { LazyLoadError } from './lazyWithGlobalErrorHandler'; +import TranslationKey from '../i18n/utils/TranslationKey'; + +const ErrorTranslationMapppings = (error: Error): TranslationKey => { + if (error instanceof LazyLoadError) { + return 'pages.error.errors.LazyLoadError'; + } + return 'pages.error.errors.unknown'; +}; export const GlobalErrorDialog: React.FC = () => { const { t } = useTranslation(); @@ -21,9 +30,7 @@ export const GlobalErrorDialog: React.FC = () => { , diff --git a/src/core/lazyLoading/lazyWithGlobalErrorHandler.ts b/src/core/lazyLoading/lazyWithGlobalErrorHandler.ts index f4de72f105..79fca6351e 100644 --- a/src/core/lazyLoading/lazyWithGlobalErrorHandler.ts +++ b/src/core/lazyLoading/lazyWithGlobalErrorHandler.ts @@ -3,6 +3,14 @@ import { getGlobalErrorSetter } from './GlobalErrorContext'; type ImportFunc = () => Promise<{ default: React.ComponentType }>; +export class LazyLoadError extends Error { + constructor(originalError: Error) { + super(originalError.message); + this.name = 'LazyLoadError'; + Object.setPrototypeOf(this, LazyLoadError.prototype); + } +} + export const lazyWithGlobalErrorHandler = ( importFunc: ImportFunc ): React.LazyExoticComponent> => { @@ -11,7 +19,10 @@ export const lazyWithGlobalErrorHandler = ( return await importFunc(); } catch (error) { const setError = getGlobalErrorSetter(); - setError(error as Error); + const originalError: Error = + error instanceof Error ? error : error?.['message'] ? new Error(error['message']) : new Error('Unknown error'); + + setError(new LazyLoadError(originalError)); // it looks like this error is already logged by the useErrorLoggerLink (network error) @@ -24,20 +35,15 @@ export const lazyWithGlobalErrorHandler = ( }); }; -class ContentUnavailableError extends Error { - constructor(message: string) { - super(message); - this.name = 'ContentUnavailableError'; - Object.setPrototypeOf(this, ContentUnavailableError.prototype); - } -} - export const lazyImportWithErrorHandler = async (importFunc: () => Promise): Promise => { try { return await importFunc(); } catch (error) { const setError = getGlobalErrorSetter(); - setError(error as Error); - throw new ContentUnavailableError((error as Error)?.message); + const originalError: Error = + error instanceof Error ? error : error?.['message'] ? new Error(error['message']) : new Error('Unknown error'); + + setError(new LazyLoadError(originalError)); + throw new LazyLoadError(originalError); } };