mirror of
https://github.com/outline/outline.git
synced 2025-12-20 10:09:43 -06:00
Add smart preloading of settings screens to reduce flicker (#9165)
This commit is contained in:
@@ -15,6 +15,7 @@ import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import InputSelectPermission from "~/components/InputSelectPermission";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
@@ -22,7 +23,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
|
||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
|
||||
|
||||
export interface FormData {
|
||||
name: string;
|
||||
@@ -88,6 +89,11 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
|
||||
const values = watch();
|
||||
|
||||
// Preload the IconPicker component on mount
|
||||
React.useEffect(() => {
|
||||
void IconPicker.preload();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
// If the user hasn't picked an icon yet, go ahead and suggest one based on
|
||||
// the name of the collection. It's the little things sometimes.
|
||||
@@ -202,7 +208,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
);
|
||||
});
|
||||
|
||||
const StyledIconPicker = styled(IconPicker)`
|
||||
const StyledIconPicker = styled(IconPicker.Component)`
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
47
app/components/LazyLoad.ts
Normal file
47
app/components/LazyLoad.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as React from "react";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
export interface LazyComponent<T extends React.ComponentType<any>> {
|
||||
Component: React.LazyExoticComponent<T>;
|
||||
preload: () => Promise<{ default: T }>;
|
||||
}
|
||||
|
||||
interface LazyLoadOptions {
|
||||
retries?: number;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a lazy-loaded component with preloading capability and automatic retries on failure.
|
||||
*
|
||||
* @param factory A function that returns a promise of a component (eg: () => import('./MyComponent'))
|
||||
* @param options Optional configuration for retry behavior
|
||||
* @returns An object containing the lazy Component and a preload function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const MyComponent = createLazyComponent(() => import('./MyComponent'));
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <Suspense fallback={<div>Loading...</div>}>
|
||||
* <MyComponent.Component />
|
||||
* </Suspense>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* // Preload when needed:
|
||||
* MyComponent.preload();
|
||||
* ```
|
||||
*/
|
||||
export function createLazyComponent<T extends React.ComponentType<any>>(
|
||||
factory: () => Promise<{ default: T }>,
|
||||
options: LazyLoadOptions = {}
|
||||
): LazyComponent<T> {
|
||||
const { retries, interval } = options;
|
||||
|
||||
return {
|
||||
Component: lazyWithRetry(factory, retries, interval),
|
||||
preload: factory,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { PopoverDisclosure, usePopoverState } from "reakit";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Popover from "~/components/Popover";
|
||||
@@ -12,7 +13,7 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
import Tooltip from "../Tooltip";
|
||||
|
||||
const EmojiPanel = React.lazy(
|
||||
const EmojiPanel = createLazyComponent(
|
||||
() => import("~/components/IconPicker/components/EmojiPanel")
|
||||
);
|
||||
|
||||
@@ -104,6 +105,7 @@ const ReactionPicker: React.FC<Props> = ({
|
||||
aria-label={t("Reaction picker")}
|
||||
className={className}
|
||||
onClick={handlePopoverButtonClick}
|
||||
onMouseEnter={() => EmojiPanel.preload()}
|
||||
size={size}
|
||||
>
|
||||
<ReactionIcon size={22} />
|
||||
@@ -123,7 +125,7 @@ const ReactionPicker: React.FC<Props> = ({
|
||||
{popover.visible && (
|
||||
<React.Suspense fallback={<Placeholder />}>
|
||||
<EventBoundary>
|
||||
<EmojiPanel
|
||||
<EmojiPanel.Component
|
||||
height={300}
|
||||
panelWidth={panelWidth}
|
||||
query={query}
|
||||
|
||||
@@ -63,6 +63,7 @@ function SettingsSidebar() {
|
||||
<SidebarLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClickIntent={item.preload}
|
||||
active={
|
||||
item.path !== settingsPath()
|
||||
? location.pathname.startsWith(item.path)
|
||||
|
||||
@@ -19,9 +19,9 @@ import React, { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
|
||||
import ZapierIcon from "~/components/Icons/ZapierIcon";
|
||||
import { createLazyComponent as lazy } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import lazy from "~/utils/lazyWithRetry";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import { useComputed } from "./useComputed";
|
||||
import useCurrentTeam from "./useCurrentTeam";
|
||||
@@ -50,6 +50,7 @@ export type ConfigItem = {
|
||||
path: string;
|
||||
icon: React.FC<ComponentProps<typeof Icon>>;
|
||||
component: React.ComponentType;
|
||||
preload?: () => void;
|
||||
enabled: boolean;
|
||||
group: string;
|
||||
};
|
||||
@@ -66,7 +67,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Profile"),
|
||||
path: settingsPath(),
|
||||
component: Profile,
|
||||
component: Profile.Component,
|
||||
preload: Profile.preload,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
icon: ProfileIcon,
|
||||
@@ -74,7 +76,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Preferences"),
|
||||
path: settingsPath("preferences"),
|
||||
component: Preferences,
|
||||
component: Preferences.Component,
|
||||
preload: Preferences.preload,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
icon: SettingsIcon,
|
||||
@@ -82,7 +85,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Notifications"),
|
||||
path: settingsPath("notifications"),
|
||||
component: Notifications,
|
||||
component: Notifications.Component,
|
||||
preload: Notifications.preload,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
icon: EmailIcon,
|
||||
@@ -90,7 +94,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("API & Apps"),
|
||||
path: settingsPath("api-and-apps"),
|
||||
component: APIAndApps,
|
||||
component: APIAndApps.Component,
|
||||
preload: APIAndApps.preload,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
icon: PadlockIcon,
|
||||
@@ -99,7 +104,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Details"),
|
||||
path: settingsPath("details"),
|
||||
component: Details,
|
||||
component: Details.Component,
|
||||
preload: Details.preload,
|
||||
enabled: can.update,
|
||||
group: t("Workspace"),
|
||||
icon: TeamIcon,
|
||||
@@ -107,7 +113,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Security"),
|
||||
path: settingsPath("security"),
|
||||
component: Security,
|
||||
component: Security.Component,
|
||||
preload: Security.preload,
|
||||
enabled: can.update,
|
||||
group: t("Workspace"),
|
||||
icon: PadlockIcon,
|
||||
@@ -115,7 +122,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Features"),
|
||||
path: settingsPath("features"),
|
||||
component: Features,
|
||||
component: Features.Component,
|
||||
preload: Features.preload,
|
||||
enabled: can.update,
|
||||
group: t("Workspace"),
|
||||
icon: BeakerIcon,
|
||||
@@ -123,7 +131,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Members"),
|
||||
path: settingsPath("members"),
|
||||
component: Members,
|
||||
component: Members.Component,
|
||||
preload: Members.preload,
|
||||
enabled: can.listUsers,
|
||||
group: t("Workspace"),
|
||||
icon: UserIcon,
|
||||
@@ -131,7 +140,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Groups"),
|
||||
path: settingsPath("groups"),
|
||||
component: Groups,
|
||||
component: Groups.Component,
|
||||
preload: Groups.preload,
|
||||
enabled: can.listGroups,
|
||||
group: t("Workspace"),
|
||||
icon: GroupIcon,
|
||||
@@ -139,7 +149,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Templates"),
|
||||
path: settingsPath("templates"),
|
||||
component: Templates,
|
||||
component: Templates.Component,
|
||||
preload: Templates.preload,
|
||||
enabled: can.readTemplate,
|
||||
group: t("Workspace"),
|
||||
icon: ShapesIcon,
|
||||
@@ -147,7 +158,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("API Keys"),
|
||||
path: settingsPath("api-keys"),
|
||||
component: ApiKeys,
|
||||
component: ApiKeys.Component,
|
||||
preload: ApiKeys.preload,
|
||||
enabled: can.listApiKeys,
|
||||
group: t("Workspace"),
|
||||
icon: CodeIcon,
|
||||
@@ -155,7 +167,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Applications"),
|
||||
path: settingsPath("applications"),
|
||||
component: Applications,
|
||||
component: Applications.Component,
|
||||
preload: Applications.preload,
|
||||
enabled: can.listOAuthClients,
|
||||
group: t("Workspace"),
|
||||
icon: InternetIcon,
|
||||
@@ -163,7 +176,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Shared Links"),
|
||||
path: settingsPath("shares"),
|
||||
component: Shares,
|
||||
component: Shares.Component,
|
||||
preload: Shares.preload,
|
||||
enabled: can.listShares,
|
||||
group: t("Workspace"),
|
||||
icon: GlobeIcon,
|
||||
@@ -171,7 +185,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Import"),
|
||||
path: settingsPath("import"),
|
||||
component: Import,
|
||||
component: Import.Component,
|
||||
preload: Import.preload,
|
||||
enabled: can.createImport,
|
||||
group: t("Workspace"),
|
||||
icon: ImportIcon,
|
||||
@@ -179,7 +194,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: t("Export"),
|
||||
path: settingsPath("export"),
|
||||
component: Export,
|
||||
component: Export.Component,
|
||||
preload: Export.preload,
|
||||
enabled: can.createExport,
|
||||
group: t("Workspace"),
|
||||
icon: ExportIcon,
|
||||
@@ -188,7 +204,8 @@ const useSettingsConfig = () => {
|
||||
{
|
||||
name: "Zapier",
|
||||
path: integrationSettingsPath("zapier"),
|
||||
component: Zapier,
|
||||
component: Zapier.Component,
|
||||
preload: Zapier.preload,
|
||||
enabled: can.update && isCloudHosted,
|
||||
group: t("Integrations"),
|
||||
icon: ZapierIcon,
|
||||
@@ -208,7 +225,8 @@ const useSettingsConfig = () => {
|
||||
? integrationSettingsPath(plugin.id)
|
||||
: settingsPath(plugin.id),
|
||||
group: t(group),
|
||||
component: plugin.value.component,
|
||||
component: plugin.value.component.Component,
|
||||
preload: plugin.value.component.preload,
|
||||
enabled: plugin.value.enabled
|
||||
? plugin.value.enabled(team, user)
|
||||
: can.update,
|
||||
|
||||
@@ -3,6 +3,7 @@ import sortBy from "lodash/sortBy";
|
||||
import { action, observable } from "mobx";
|
||||
import Team from "~/models/Team";
|
||||
import User from "~/models/User";
|
||||
import { LazyComponent } from "~/components/LazyLoad";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
import Logger from "./Logger";
|
||||
import isCloudHosted from "./isCloudHosted";
|
||||
@@ -28,7 +29,7 @@ type PluginValueMap = {
|
||||
/** The displayed icon of the plugin. */
|
||||
icon: React.ElementType;
|
||||
/** The settings screen somponent, should be lazy loaded. */
|
||||
component: React.LazyExoticComponent<React.ComponentType>;
|
||||
component: LazyComponent<React.ComponentType>;
|
||||
/** Whether the plugin is enabled in the current context. */
|
||||
enabled?: (team: Team, user: User) => boolean;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -10,7 +10,7 @@ PluginManager.add([
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -10,7 +10,7 @@ PluginManager.add([
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -10,7 +10,7 @@ PluginManager.add([
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -11,7 +11,7 @@ PluginManager.add([
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
enabled: (_, user) => user.role === UserRole.Admin,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -11,7 +11,7 @@ PluginManager.add([
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
enabled: (_, user) =>
|
||||
[UserRole.Member, UserRole.Admin].includes(user.role),
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -11,7 +11,7 @@ PluginManager.add([
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
enabled: (_, user) => user.role === UserRole.Admin,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
@@ -10,7 +10,7 @@ PluginManager.add([
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user