diff --git a/app/components/AuthenticatedLayout.tsx b/app/components/AuthenticatedLayout.tsx index 37f0241956..06a58ac3ac 100644 --- a/app/components/AuthenticatedLayout.tsx +++ b/app/components/AuthenticatedLayout.tsx @@ -15,6 +15,7 @@ import type { Editor as TEditor } from "~/editor"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import history from "~/utils/history"; +import lazyWithRetry from "~/utils/lazyWithRetry"; import { searchPath, newDocumentPath, @@ -25,16 +26,16 @@ import { } from "~/utils/routeHelpers"; import Fade from "./Fade"; -const DocumentComments = React.lazy( +const DocumentComments = lazyWithRetry( () => import("~/scenes/Document/components/Comments") ); -const DocumentHistory = React.lazy( +const DocumentHistory = lazyWithRetry( () => import("~/scenes/Document/components/History") ); -const DocumentInsights = React.lazy( +const DocumentInsights = lazyWithRetry( () => import("~/scenes/Document/components/Insights") ); -const CommandBar = React.lazy(() => import("~/components/CommandBar")); +const CommandBar = lazyWithRetry(() => import("~/components/CommandBar")); const AuthenticatedLayout: React.FC = ({ children }) => { const { ui, auth } = useStores(); diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index 6e533003ed..539f34a41b 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -26,11 +26,12 @@ import useToasts from "~/hooks/useToasts"; import { NotFoundError } from "~/utils/errors"; import { uploadFile } from "~/utils/files"; import { isModKey } from "~/utils/keyboard"; +import lazyWithRetry from "~/utils/lazyWithRetry"; import { sharedDocumentPath } from "~/utils/routeHelpers"; import { isHash } from "~/utils/urls"; import DocumentBreadcrumb from "./DocumentBreadcrumb"; -const LazyLoadedEditor = React.lazy(() => import("~/editor")); +const LazyLoadedEditor = lazyWithRetry(() => import("~/editor")); export type Props = Optional< EditorProps, diff --git a/app/components/IconPicker.tsx b/app/components/IconPicker.tsx index eb048ffa84..84a861d9c5 100644 --- a/app/components/IconPicker.tsx +++ b/app/components/IconPicker.tsx @@ -47,6 +47,7 @@ import Flex from "~/components/Flex"; import { LabelText } from "~/components/Input"; import NudeButton from "~/components/NudeButton"; import Text from "~/components/Text"; +import lazyWithRetry from "~/utils/lazyWithRetry"; import DelayedMount from "./DelayedMount"; const style = { @@ -54,7 +55,7 @@ const style = { height: 30, }; -const TwitterPicker = React.lazy( +const TwitterPicker = lazyWithRetry( () => import("react-color/lib/components/twitter/Twitter") ); diff --git a/app/components/InputColor.tsx b/app/components/InputColor.tsx index 12fa3db93b..271c600884 100644 --- a/app/components/InputColor.tsx +++ b/app/components/InputColor.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { MenuButton, useMenuState } from "reakit/Menu"; import styled from "styled-components"; import { s } from "@shared/styles"; +import lazyWithRetry from "~/utils/lazyWithRetry"; import ContextMenu from "./ContextMenu"; import DelayedMount from "./DelayedMount"; import Input, { Props as InputProps } from "./Input"; @@ -68,7 +69,7 @@ const SwatchButton = styled(NudeButton)<{ $background: string | undefined }>` right: 6px; `; -const ColorPicker = React.lazy( +const ColorPicker = lazyWithRetry( () => import("react-color/lib/components/chrome/Chrome") ); diff --git a/app/components/TableFromParams.tsx b/app/components/TableFromParams.tsx index 20ab0391c4..f0c0ad6d15 100644 --- a/app/components/TableFromParams.tsx +++ b/app/components/TableFromParams.tsx @@ -3,9 +3,10 @@ import * as React from "react"; import { useHistory, useLocation } from "react-router-dom"; import scrollIntoView from "smooth-scroll-into-view-if-needed"; import useQuery from "~/hooks/useQuery"; +import lazyWithRetry from "~/utils/lazyWithRetry"; import type { Props } from "./Table"; -const Table = React.lazy(() => import("~/components/Table")); +const Table = lazyWithRetry(() => import("~/components/Table")); const TableFromParams = ( props: Omit diff --git a/app/components/Time.tsx b/app/components/Time.tsx index 144a4110e8..29247531e0 100644 --- a/app/components/Time.tsx +++ b/app/components/Time.tsx @@ -1,7 +1,8 @@ import { formatDistanceToNow } from "date-fns"; import * as React from "react"; +import lazyWithRetry from "~/utils/lazyWithRetry"; -const LocaleTime = React.lazy(() => import("~/components/LocaleTime")); +const LocaleTime = lazyWithRetry(() => import("~/components/LocaleTime")); type Props = React.ComponentProps & { onClick?: () => void; diff --git a/app/routes/authenticated.tsx b/app/routes/authenticated.tsx index 88d4096357..08133c906f 100644 --- a/app/routes/authenticated.tsx +++ b/app/routes/authenticated.tsx @@ -1,12 +1,8 @@ import { observer } from "mobx-react"; import * as React from "react"; import { Switch, Redirect, RouteComponentProps } from "react-router-dom"; -import Archive from "~/scenes/Archive"; import DocumentNew from "~/scenes/DocumentNew"; -import Drafts from "~/scenes/Drafts"; import Error404 from "~/scenes/Error404"; -import Templates from "~/scenes/Templates"; -import Trash from "~/scenes/Trash"; import AuthenticatedLayout from "~/components/AuthenticatedLayout"; import CenteredContent from "~/components/CenteredContent"; import PlaceholderDocument from "~/components/PlaceholderDocument"; @@ -14,13 +10,18 @@ import Route from "~/components/ProfiledRoute"; import WebsocketProvider from "~/components/WebsocketProvider"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import usePolicy from "~/hooks/usePolicy"; +import lazyWithRetry from "~/utils/lazyWithRetry"; import { matchDocumentSlug as slug } from "~/utils/routeHelpers"; -const SettingsRoutes = React.lazy(() => import("./settings")); -const Document = React.lazy(() => import("~/scenes/Document")); -const Collection = React.lazy(() => import("~/scenes/Collection")); -const Home = React.lazy(() => import("~/scenes/Home")); -const Search = React.lazy(() => import("~/scenes/Search")); +const SettingsRoutes = lazyWithRetry(() => import("./settings")); +const Archive = lazyWithRetry(() => import("~/scenes/Archive")); +const Collection = lazyWithRetry(() => import("~/scenes/Collection")); +const Document = lazyWithRetry(() => import("~/scenes/Document")); +const Drafts = lazyWithRetry(() => import("~/scenes/Drafts")); +const Home = lazyWithRetry(() => import("~/scenes/Home")); +const Templates = lazyWithRetry(() => import("~/scenes/Templates")); +const Search = lazyWithRetry(() => import("~/scenes/Search")); +const Trash = lazyWithRetry(() => import("~/scenes/Trash")); const RedirectDocument = ({ match, diff --git a/app/routes/index.tsx b/app/routes/index.tsx index f5c6618f6e..a7eee6306c 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -4,13 +4,14 @@ import DesktopRedirect from "~/scenes/DesktopRedirect"; import DelayedMount from "~/components/DelayedMount"; import FullscreenLoading from "~/components/FullscreenLoading"; import Route from "~/components/ProfiledRoute"; +import lazyWithRetry from "~/utils/lazyWithRetry"; import { matchDocumentSlug as slug } from "~/utils/routeHelpers"; -const Authenticated = React.lazy(() => import("~/components/Authenticated")); -const AuthenticatedRoutes = React.lazy(() => import("./authenticated")); -const SharedDocument = React.lazy(() => import("~/scenes/Document/Shared")); -const Login = React.lazy(() => import("~/scenes/Login")); -const Logout = React.lazy(() => import("~/scenes/Logout")); +const Authenticated = lazyWithRetry(() => import("~/components/Authenticated")); +const AuthenticatedRoutes = lazyWithRetry(() => import("./authenticated")); +const SharedDocument = lazyWithRetry(() => import("~/scenes/Document/Shared")); +const Login = lazyWithRetry(() => import("~/scenes/Login")); +const Logout = lazyWithRetry(() => import("~/scenes/Logout")); export default function Routes() { return ( diff --git a/app/scenes/Document/components/AsyncMultiplayerEditor.ts b/app/scenes/Document/components/AsyncMultiplayerEditor.ts index f99c0c6eb8..0da5ce9832 100644 --- a/app/scenes/Document/components/AsyncMultiplayerEditor.ts +++ b/app/scenes/Document/components/AsyncMultiplayerEditor.ts @@ -1,5 +1,5 @@ -import * as React from "react"; +import lazyWithRetry from "~/utils/lazyWithRetry"; -const MultiplayerEditor = React.lazy(() => import("./MultiplayerEditor")); +const MultiplayerEditor = lazyWithRetry(() => import("./MultiplayerEditor")); export default MultiplayerEditor; diff --git a/app/utils/lazyWithRetry.ts b/app/utils/lazyWithRetry.ts new file mode 100644 index 0000000000..1620bee123 --- /dev/null +++ b/app/utils/lazyWithRetry.ts @@ -0,0 +1,41 @@ +import * as React from "react"; + +type ComponentPromise> = Promise<{ + default: T; +}>; + +/** + * Lazy load a component with automatic retry on failure. + * + * @param component A function that returns a promise of a component. + * @param retries The number of retries, defaults to 3. + * @param interval The interval between retries in milliseconds, defaults to 1000. + * @returns A lazy component. + */ +export default function lazyWithRetry>( + component: () => ComponentPromise, + retries?: number, + interval?: number +): React.LazyExoticComponent { + return React.lazy(() => retry(component, retries, interval)); +} + +function retry>( + fn: () => ComponentPromise, + retriesLeft = 3, + interval = 1000 +): ComponentPromise { + return new Promise((resolve, reject) => { + fn() + .then(resolve) + .catch((error) => { + setTimeout(() => { + if (retriesLeft === 1) { + reject(error); + return; + } + retry(fn, retriesLeft - 1, interval).then(resolve, reject); + }, interval); + }); + }); +}