From c45a1a5ad4047389bc9c757148659ee888d6e599 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Fri, 26 Jul 2024 12:42:47 +0800 Subject: [PATCH] refactor(console): safely lazy load pages (#6332) * refactor(console): safely lazy load pages * chore(console): use react-safe-lazy --- packages/console/.eslintrc.cjs | 9 +-- packages/console/package.json | 1 + .../src/containers/ConsoleContent/index.tsx | 5 +- .../src/containers/ConsoleRoutes/index.tsx | 65 +++++++++++-------- .../src/containers/ConsoleRoutes/internal.ts | 14 ++++ .../src/hooks/use-console-routes/index.tsx | 9 +-- .../routes/api-resources.tsx | 10 +-- .../routes/applications.tsx | 8 +-- .../use-console-routes/routes/audit-logs.tsx | 6 +- .../use-console-routes/routes/connectors.tsx | 6 +- .../routes/customize-jwt.tsx | 6 +- .../routes/enterprise-sso.tsx | 6 +- .../hooks/use-console-routes/routes/mfa.tsx | 4 +- .../routes/organization-template.tsx | 14 ++-- .../routes/organizations.tsx | 14 ++-- .../use-console-routes/routes/profile.tsx | 10 +-- .../hooks/use-console-routes/routes/roles.tsx | 14 ++-- .../routes/sign-in-experience.tsx | 4 +- .../routes/tenant-settings.tsx | 23 ++++--- .../hooks/use-console-routes/routes/users.tsx | 16 ++--- .../use-console-routes/routes/webhooks.tsx | 12 ++-- .../src/tests/console/error-handling.test.ts | 46 +++++++++++++ .../experience/server-side-rendering.test.ts | 53 +-------------- .../integration-tests/src/ui-helpers/trace.ts | 54 +++++++++++++++ pnpm-lock.yaml | 12 ++++ 25 files changed, 257 insertions(+), 164 deletions(-) create mode 100644 packages/console/src/containers/ConsoleRoutes/internal.ts create mode 100644 packages/integration-tests/src/tests/console/error-handling.test.ts create mode 100644 packages/integration-tests/src/ui-helpers/trace.ts diff --git a/packages/console/.eslintrc.cjs b/packages/console/.eslintrc.cjs index a350201e77d..0997dc7409c 100644 --- a/packages/console/.eslintrc.cjs +++ b/packages/console/.eslintrc.cjs @@ -15,6 +15,7 @@ module.exports = { unnamedComponents: 'arrow-function', }, ], + 'react/jsx-pascal-case': ['error', { ignore: ['__Internal__*'] }], 'import/no-unused-modules': [ 'error', { @@ -30,6 +31,8 @@ module.exports = { '**/assets/docs/guides/*/index.ts', '**/assets/docs/guides/*/components/**/*.tsx', '**/mdx-components*/*/index.tsx', + '*.config.js', + '*.config.ts', ], rules: { 'import/no-unused-modules': 'off', @@ -49,12 +52,6 @@ module.exports = { ], }, }, - { - files: ['*.config.js', '*.config.ts', '*.d.ts'], - rules: { - 'import/no-unused-modules': 'off', - }, - }, { files: ['*.d.ts'], rules: { diff --git a/packages/console/package.json b/packages/console/package.json index aee57fe6cc0..4e24b8687ba 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -107,6 +107,7 @@ "react-modal": "^3.15.1", "react-paginate": "^8.1.3", "react-router-dom": "^6.25.1", + "react-safe-lazy": "^0.1.0", "react-syntax-highlighter": "^15.5.0", "react-timer-hook": "^3.0.5", "recharts": "^2.1.13", diff --git a/packages/console/src/containers/ConsoleContent/index.tsx b/packages/console/src/containers/ConsoleContent/index.tsx index 49ff9967470..d063da006ec 100644 --- a/packages/console/src/containers/ConsoleContent/index.tsx +++ b/packages/console/src/containers/ConsoleContent/index.tsx @@ -1,5 +1,6 @@ -import { lazy, Suspense } from 'react'; +import { Suspense } from 'react'; import { useOutletContext, useRoutes } from 'react-router-dom'; +import { safeLazy } from 'react-safe-lazy'; import { isDevFeaturesEnabled } from '@/consts/env'; import OverlayScrollbar from '@/ds-components/OverlayScrollbar'; @@ -14,7 +15,7 @@ import { Skeleton } from './Sidebar'; import useTenantScopeListener from './hooks'; import styles from './index.module.scss'; -const Sidebar = lazy(async () => import('./Sidebar')); +const Sidebar = safeLazy(async () => import('./Sidebar')); function ConsoleContent() { const { scrollableContent } = useOutletContext(); diff --git a/packages/console/src/containers/ConsoleRoutes/index.tsx b/packages/console/src/containers/ConsoleRoutes/index.tsx index 4e4e2369f20..f3834d514d3 100644 --- a/packages/console/src/containers/ConsoleRoutes/index.tsx +++ b/packages/console/src/containers/ConsoleRoutes/index.tsx @@ -1,8 +1,11 @@ import { ossConsolePath } from '@logto/schemas'; +import { Suspense } from 'react'; import { Navigate, Outlet, Route, Routes } from 'react-router-dom'; +import { safeLazy } from 'react-safe-lazy'; import { SWRConfig } from 'swr'; -import { isCloud } from '@/consts/env'; +import AppLoading from '@/components/AppLoading'; +import { isCloud, isDevFeaturesEnabled } from '@/consts/env'; import AppBoundary from '@/containers/AppBoundary'; import AppContent, { RedirectToFirstItem } from '@/containers/AppContent'; import ConsoleContent from '@/containers/ConsoleContent'; @@ -12,10 +15,13 @@ import { GlobalRoute } from '@/contexts/TenantsProvider'; import useSwrOptions from '@/hooks/use-swr-options'; import Callback from '@/pages/Callback'; import CheckoutSuccessCallback from '@/pages/CheckoutSuccessCallback'; -import Profile from '@/pages/Profile'; -import Welcome from '@/pages/Welcome'; import { dropLeadingSlash } from '@/utils/url'; +import { __Internal__ImportError } from './internal'; + +const Welcome = safeLazy(async () => import('@/pages/Welcome')); +const Profile = safeLazy(async () => import('@/pages/Profile')); + function Layout() { const swrOptions = useSwrOptions(); @@ -30,32 +36,37 @@ function Layout() { export function ConsoleRoutes() { return ( - - {/** - * OSS doesn't have a tenant concept nor root path handling component, but it may - * navigate to the root path in frontend. In this case, we redirect it to the OSS - * console path to trigger the console routes. - */} - {!isCloud && } />} - }> - } /> - } /> - }> - } /> - }> - {isCloud && ( - } - /> - )} - }> - } /> - } /> + }> + + {/** + * OSS doesn't have a tenant concept nor root path handling component, but it may + * navigate to the root path in frontend. In this case, we redirect it to the OSS + * console path to trigger the console routes. + */} + {!isCloud && } />} + }> + } /> + } /> + {isDevFeaturesEnabled && ( + } /> + )} + }> + } /> + }> + {isCloud && ( + } + /> + )} + }> + } /> + } /> + - - + + ); } diff --git a/packages/console/src/containers/ConsoleRoutes/internal.ts b/packages/console/src/containers/ConsoleRoutes/internal.ts new file mode 100644 index 00000000000..efa055872e4 --- /dev/null +++ b/packages/console/src/containers/ConsoleRoutes/internal.ts @@ -0,0 +1,14 @@ +import { safeLazy } from 'react-safe-lazy'; + +/** + * An internal module that is used to test the lazy loading failure in the console. Normally, this + * module should not involve any production code. + */ +export const __Internal__ImportError = safeLazy(async () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const module = await import( + /* @vite-ignore */ `${window.location.origin}/some-non-existing-path` + ); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return module; +}); diff --git a/packages/console/src/hooks/use-console-routes/index.tsx b/packages/console/src/hooks/use-console-routes/index.tsx index 4b78bbc2de9..85e8b4d190a 100644 --- a/packages/console/src/hooks/use-console-routes/index.tsx +++ b/packages/console/src/hooks/use-console-routes/index.tsx @@ -1,6 +1,7 @@ import { condArray } from '@silverhand/essentials'; -import { lazy, useMemo } from 'react'; +import { useMemo } from 'react'; import { type RouteObject } from 'react-router-dom'; +import { safeLazy } from 'react-safe-lazy'; import { isCloud } from '@/consts/env'; import NotFound from '@/pages/NotFound'; @@ -20,9 +21,9 @@ import { useTenantSettings } from './routes/tenant-settings'; import { users } from './routes/users'; import { webhooks } from './routes/webhooks'; -const Dashboard = lazy(async () => import('@/pages/Dashboard')); -const GetStarted = lazy(async () => import('@/pages/GetStarted')); -const SigningKeys = lazy(async () => import('@/pages/SigningKeys')); +const Dashboard = safeLazy(async () => import('@/pages/Dashboard')); +const GetStarted = safeLazy(async () => import('@/pages/GetStarted')); +const SigningKeys = safeLazy(async () => import('@/pages/SigningKeys')); export const useConsoleRoutes = () => { const tenantSettings = useTenantSettings(); diff --git a/packages/console/src/hooks/use-console-routes/routes/api-resources.tsx b/packages/console/src/hooks/use-console-routes/routes/api-resources.tsx index 3bdd236135d..8944f309cc2 100644 --- a/packages/console/src/hooks/use-console-routes/routes/api-resources.tsx +++ b/packages/console/src/hooks/use-console-routes/routes/api-resources.tsx @@ -1,14 +1,14 @@ -import { lazy } from 'react'; import { Navigate, type RouteObject } from 'react-router-dom'; +import { safeLazy } from 'react-safe-lazy'; import { ApiResourceDetailsTabs } from '@/consts'; -const ApiResources = lazy(async () => import('@/pages/ApiResources')); -const ApiResourceDetails = lazy(async () => import('@/pages/ApiResourceDetails')); -const ApiResourcePermissions = lazy( +const ApiResources = safeLazy(async () => import('@/pages/ApiResources')); +const ApiResourceDetails = safeLazy(async () => import('@/pages/ApiResourceDetails')); +const ApiResourcePermissions = safeLazy( async () => import('@/pages/ApiResourceDetails/ApiResourcePermissions') ); -const ApiResourceSettings = lazy( +const ApiResourceSettings = safeLazy( async () => import('@/pages/ApiResourceDetails/ApiResourceSettings') ); diff --git a/packages/console/src/hooks/use-console-routes/routes/applications.tsx b/packages/console/src/hooks/use-console-routes/routes/applications.tsx index e4c178db175..bdb27c803d7 100644 --- a/packages/console/src/hooks/use-console-routes/routes/applications.tsx +++ b/packages/console/src/hooks/use-console-routes/routes/applications.tsx @@ -1,11 +1,11 @@ -import { lazy } from 'react'; import { Navigate, type RouteObject } from 'react-router-dom'; +import { safeLazy } from 'react-safe-lazy'; import { ApplicationDetailsTabs } from '@/consts'; -const Applications = lazy(async () => import('@/pages/Applications')); -const ApplicationDetails = lazy(async () => import('@/pages/ApplicationDetails')); -const AuditLogDetails = lazy(async () => import('@/pages/AuditLogDetails')); +const Applications = safeLazy(async () => import('@/pages/Applications')); +const ApplicationDetails = safeLazy(async () => import('@/pages/ApplicationDetails')); +const AuditLogDetails = safeLazy(async () => import('@/pages/AuditLogDetails')); export const applications: RouteObject = { path: 'applications', diff --git a/packages/console/src/hooks/use-console-routes/routes/audit-logs.tsx b/packages/console/src/hooks/use-console-routes/routes/audit-logs.tsx index e529342119c..626c91527e6 100644 --- a/packages/console/src/hooks/use-console-routes/routes/audit-logs.tsx +++ b/packages/console/src/hooks/use-console-routes/routes/audit-logs.tsx @@ -1,8 +1,8 @@ -import { lazy } from 'react'; import { type RouteObject } from 'react-router-dom'; +import { safeLazy } from 'react-safe-lazy'; -const AuditLogs = lazy(async () => import('@/pages/AuditLogs')); -const AuditLogDetails = lazy(async () => import('@/pages/AuditLogDetails')); +const AuditLogs = safeLazy(async () => import('@/pages/AuditLogs')); +const AuditLogDetails = safeLazy(async () => import('@/pages/AuditLogDetails')); export const auditLogs: RouteObject = { path: 'audit-logs', diff --git a/packages/console/src/hooks/use-console-routes/routes/connectors.tsx b/packages/console/src/hooks/use-console-routes/routes/connectors.tsx index dbe359ba2d7..f79079393c5 100644 --- a/packages/console/src/hooks/use-console-routes/routes/connectors.tsx +++ b/packages/console/src/hooks/use-console-routes/routes/connectors.tsx @@ -1,10 +1,10 @@ -import { lazy } from 'react'; import { Navigate } from 'react-router-dom'; +import { safeLazy } from 'react-safe-lazy'; import { ConnectorsTabs } from '@/consts'; -const Connectors = lazy(async () => import('@/pages/Connectors')); -const ConnectorDetails = lazy(async () => import('@/pages/ConnectorDetails')); +const Connectors = safeLazy(async () => import('@/pages/Connectors')); +const ConnectorDetails = safeLazy(async () => import('@/pages/ConnectorDetails')); export const connectors = { path: 'connectors', diff --git a/packages/console/src/hooks/use-console-routes/routes/customize-jwt.tsx b/packages/console/src/hooks/use-console-routes/routes/customize-jwt.tsx index b5d8ec0379f..24b3fe44cc2 100644 --- a/packages/console/src/hooks/use-console-routes/routes/customize-jwt.tsx +++ b/packages/console/src/hooks/use-console-routes/routes/customize-jwt.tsx @@ -1,8 +1,8 @@ -import { lazy } from 'react'; import { type RouteObject } from 'react-router-dom'; +import { safeLazy } from 'react-safe-lazy'; -const CustomizeJwt = lazy(async () => import('@/pages/CustomizeJwt')); -const CustomizeJwtDetails = lazy(async () => import('@/pages/CustomizeJwtDetails')); +const CustomizeJwt = safeLazy(async () => import('@/pages/CustomizeJwt')); +const CustomizeJwtDetails = safeLazy(async () => import('@/pages/CustomizeJwtDetails')); export const customizeJwt: RouteObject = { path: 'customize-jwt', diff --git a/packages/console/src/hooks/use-console-routes/routes/enterprise-sso.tsx b/packages/console/src/hooks/use-console-routes/routes/enterprise-sso.tsx index caa7c174398..a70e61975a9 100644 --- a/packages/console/src/hooks/use-console-routes/routes/enterprise-sso.tsx +++ b/packages/console/src/hooks/use-console-routes/routes/enterprise-sso.tsx @@ -1,10 +1,10 @@ -import { lazy } from 'react'; import { Navigate, type RouteObject } from 'react-router-dom'; +import { safeLazy } from 'react-safe-lazy'; import { EnterpriseSsoDetailsTabs } from '@/consts/page-tabs'; -const EnterpriseSso = lazy(async () => import('@/pages/EnterpriseSso')); -const EnterpriseSsoDetails = lazy(async () => import('@/pages/EnterpriseSsoDetails')); +const EnterpriseSso = safeLazy(async () => import('@/pages/EnterpriseSso')); +const EnterpriseSsoDetails = safeLazy(async () => import('@/pages/EnterpriseSsoDetails')); export const enterpriseSso: RouteObject = { path: 'enterprise-sso', diff --git a/packages/console/src/hooks/use-console-routes/routes/mfa.tsx b/packages/console/src/hooks/use-console-routes/routes/mfa.tsx index 0ef3fde5acf..b35ccdb7fe4 100644 --- a/packages/console/src/hooks/use-console-routes/routes/mfa.tsx +++ b/packages/console/src/hooks/use-console-routes/routes/mfa.tsx @@ -1,6 +1,6 @@ -import { lazy } from 'react'; import { type RouteObject } from 'react-router-dom'; +import { safeLazy } from 'react-safe-lazy'; -const Mfa = lazy(async () => import('@/pages/Mfa')); +const Mfa = safeLazy(async () => import('@/pages/Mfa')); export const mfa: RouteObject = { path: 'mfa', element: }; diff --git a/packages/console/src/hooks/use-console-routes/routes/organization-template.tsx b/packages/console/src/hooks/use-console-routes/routes/organization-template.tsx index aa69cf6e387..6242c363c68 100644 --- a/packages/console/src/hooks/use-console-routes/routes/organization-template.tsx +++ b/packages/console/src/hooks/use-console-routes/routes/organization-template.tsx @@ -1,18 +1,18 @@ -import { lazy } from 'react'; import { Navigate, type RouteObject } from 'react-router-dom'; +import { safeLazy } from 'react-safe-lazy'; import { OrganizationRoleDetailsTabs, OrganizationTemplateTabs } from '@/consts'; -const OrganizationTemplate = lazy(async () => import('@/pages/OrganizationTemplate')); -const OrganizationRoles = lazy( +const OrganizationTemplate = safeLazy(async () => import('@/pages/OrganizationTemplate')); +const OrganizationRoles = safeLazy( async () => import('@/pages/OrganizationTemplate/OrganizationRoles') ); -const OrganizationPermissions = lazy( +const OrganizationPermissions = safeLazy( async () => import('@/pages/OrganizationTemplate/OrganizationPermissions') ); -const OrganizationRoleDetails = lazy(async () => import('@/pages/OrganizationRoleDetails')); -const Permissions = lazy(async () => import('@/pages/OrganizationRoleDetails/Permissions')); -const Settings = lazy(async () => import('@/pages/OrganizationRoleDetails/Settings')); +const OrganizationRoleDetails = safeLazy(async () => import('@/pages/OrganizationRoleDetails')); +const Permissions = safeLazy(async () => import('@/pages/OrganizationRoleDetails/Permissions')); +const Settings = safeLazy(async () => import('@/pages/OrganizationRoleDetails/Settings')); export const organizationTemplate: RouteObject[] = [ { diff --git a/packages/console/src/hooks/use-console-routes/routes/organizations.tsx b/packages/console/src/hooks/use-console-routes/routes/organizations.tsx index c8c95668aa3..20f73d6c90b 100644 --- a/packages/console/src/hooks/use-console-routes/routes/organizations.tsx +++ b/packages/console/src/hooks/use-console-routes/routes/organizations.tsx @@ -1,14 +1,16 @@ import { condArray } from '@silverhand/essentials'; -import { lazy } from 'react'; import { Navigate, type RouteObject } from 'react-router-dom'; +import { safeLazy } from 'react-safe-lazy'; import { OrganizationDetailsTabs } from '@/pages/OrganizationDetails/types'; -const Organizations = lazy(async () => import('@/pages/Organizations')); -const OrganizationDetails = lazy(async () => import('@/pages/OrganizationDetails')); -const MachineToMachine = lazy(async () => import('@/pages/OrganizationDetails/MachineToMachine')); -const Members = lazy(async () => import('@/pages/OrganizationDetails/Members')); -const Settings = lazy(async () => import('@/pages/OrganizationDetails/Settings')); +const Organizations = safeLazy(async () => import('@/pages/Organizations')); +const OrganizationDetails = safeLazy(async () => import('@/pages/OrganizationDetails')); +const MachineToMachine = safeLazy( + async () => import('@/pages/OrganizationDetails/MachineToMachine') +); +const Members = safeLazy(async () => import('@/pages/OrganizationDetails/Members')); +const Settings = safeLazy(async () => import('@/pages/OrganizationDetails/Settings')); export const organizations: RouteObject = { path: 'organizations', diff --git a/packages/console/src/hooks/use-console-routes/routes/profile.tsx b/packages/console/src/hooks/use-console-routes/routes/profile.tsx index 423030de425..17cdb177649 100644 --- a/packages/console/src/hooks/use-console-routes/routes/profile.tsx +++ b/packages/console/src/hooks/use-console-routes/routes/profile.tsx @@ -1,14 +1,14 @@ -import { lazy } from 'react'; import { type RouteObject } from 'react-router-dom'; +import { safeLazy } from 'react-safe-lazy'; -const ChangePasswordModal = lazy( +const ChangePasswordModal = safeLazy( async () => import('@/pages/Profile/containers/ChangePasswordModal') ); -const LinkEmailModal = lazy(async () => import('@/pages/Profile/containers/LinkEmailModal')); -const VerificationCodeModal = lazy( +const LinkEmailModal = safeLazy(async () => import('@/pages/Profile/containers/LinkEmailModal')); +const VerificationCodeModal = safeLazy( async () => import('@/pages/Profile/containers/VerificationCodeModal') ); -const VerifyPasswordModal = lazy( +const VerifyPasswordModal = safeLazy( async () => import('@/pages/Profile/containers/VerifyPasswordModal') ); diff --git a/packages/console/src/hooks/use-console-routes/routes/roles.tsx b/packages/console/src/hooks/use-console-routes/routes/roles.tsx index 51e38899e7a..73a1269eb88 100644 --- a/packages/console/src/hooks/use-console-routes/routes/roles.tsx +++ b/packages/console/src/hooks/use-console-routes/routes/roles.tsx @@ -1,14 +1,14 @@ -import { lazy } from 'react'; import { Navigate, type RouteObject } from 'react-router-dom'; +import { safeLazy } from 'react-safe-lazy'; import { RoleDetailsTabs } from '@/consts/page-tabs'; -const Roles = lazy(async () => import('@/pages/Roles')); -const RoleDetails = lazy(async () => import('@/pages/RoleDetails')); -const RolePermissions = lazy(async () => import('@/pages/RoleDetails/RolePermissions')); -const RoleSettings = lazy(async () => import('@/pages/RoleDetails/RoleSettings')); -const RoleUsers = lazy(async () => import('@/pages/RoleDetails/RoleUsers')); -const RoleApplications = lazy(async () => import('@/pages/RoleDetails/RoleApplications')); +const Roles = safeLazy(async () => import('@/pages/Roles')); +const RoleDetails = safeLazy(async () => import('@/pages/RoleDetails')); +const RolePermissions = safeLazy(async () => import('@/pages/RoleDetails/RolePermissions')); +const RoleSettings = safeLazy(async () => import('@/pages/RoleDetails/RoleSettings')); +const RoleUsers = safeLazy(async () => import('@/pages/RoleDetails/RoleUsers')); +const RoleApplications = safeLazy(async () => import('@/pages/RoleDetails/RoleApplications')); export const roles: RouteObject = { path: 'roles', diff --git a/packages/console/src/hooks/use-console-routes/routes/sign-in-experience.tsx b/packages/console/src/hooks/use-console-routes/routes/sign-in-experience.tsx index 63b4a2e8e1d..f0c705ff9a2 100644 --- a/packages/console/src/hooks/use-console-routes/routes/sign-in-experience.tsx +++ b/packages/console/src/hooks/use-console-routes/routes/sign-in-experience.tsx @@ -1,9 +1,9 @@ -import { lazy } from 'react'; import { Navigate, type RouteObject } from 'react-router-dom'; +import { safeLazy } from 'react-safe-lazy'; import { SignInExperienceTab } from '@/pages/SignInExperience/types'; -const SignInExperience = lazy(async () => import('@/pages/SignInExperience')); +const SignInExperience = safeLazy(async () => import('@/pages/SignInExperience')); export const signInExperience: RouteObject = { path: 'sign-in-experience', diff --git a/packages/console/src/hooks/use-console-routes/routes/tenant-settings.tsx b/packages/console/src/hooks/use-console-routes/routes/tenant-settings.tsx index 5c025626e82..767d9c33595 100644 --- a/packages/console/src/hooks/use-console-routes/routes/tenant-settings.tsx +++ b/packages/console/src/hooks/use-console-routes/routes/tenant-settings.tsx @@ -1,22 +1,27 @@ import { condArray } from '@silverhand/essentials'; -import { lazy, useContext, useMemo } from 'react'; +import { useContext, useMemo } from 'react'; import { Navigate, type RouteObject } from 'react-router-dom'; +import { safeLazy } from 'react-safe-lazy'; import { TenantSettingsTabs } from '@/consts'; import { TenantsContext } from '@/contexts/TenantsProvider'; import useCurrentTenantScopes from '@/hooks/use-current-tenant-scopes'; import NotFound from '@/pages/NotFound'; -const TenantSettings = lazy(async () => import('@/pages/TenantSettings')); -const TenantBasicSettings = lazy(async () => import('@/pages/TenantSettings/TenantBasicSettings')); -const TenantDomainSettings = lazy( +const TenantSettings = safeLazy(async () => import('@/pages/TenantSettings')); +const TenantBasicSettings = safeLazy( + async () => import('@/pages/TenantSettings/TenantBasicSettings') +); +const TenantDomainSettings = safeLazy( async () => import('@/pages/TenantSettings/TenantDomainSettings') ); -const TenantMembers = lazy(async () => import('@/pages/TenantSettings/TenantMembers')); -const Invitations = lazy(async () => import('@/pages/TenantSettings/TenantMembers/Invitations')); -const Members = lazy(async () => import('@/pages/TenantSettings/TenantMembers/Members')); -const BillingHistory = lazy(async () => import('@/pages/TenantSettings/BillingHistory')); -const Subscription = lazy(async () => import('@/pages/TenantSettings/Subscription')); +const TenantMembers = safeLazy(async () => import('@/pages/TenantSettings/TenantMembers')); +const Invitations = safeLazy( + async () => import('@/pages/TenantSettings/TenantMembers/Invitations') +); +const Members = safeLazy(async () => import('@/pages/TenantSettings/TenantMembers/Members')); +const BillingHistory = safeLazy(async () => import('@/pages/TenantSettings/BillingHistory')); +const Subscription = safeLazy(async () => import('@/pages/TenantSettings/Subscription')); export const useTenantSettings = () => { const { isDevTenant } = useContext(TenantsContext); diff --git a/packages/console/src/hooks/use-console-routes/routes/users.tsx b/packages/console/src/hooks/use-console-routes/routes/users.tsx index 2a4d30f99c1..0bad4fc5237 100644 --- a/packages/console/src/hooks/use-console-routes/routes/users.tsx +++ b/packages/console/src/hooks/use-console-routes/routes/users.tsx @@ -1,15 +1,15 @@ -import { lazy } from 'react'; import { Navigate, type RouteObject } from 'react-router-dom'; +import { safeLazy } from 'react-safe-lazy'; import { UserDetailsTabs } from '@/consts/page-tabs'; -const AuditLogDetails = lazy(async () => import('@/pages/AuditLogDetails')); -const UserDetails = lazy(async () => import('@/pages/UserDetails')); -const UserLogs = lazy(async () => import('@/pages/UserDetails/UserLogs')); -const UserOrganizations = lazy(async () => import('@/pages/UserDetails/UserOrganizations')); -const UserRoles = lazy(async () => import('@/pages/UserDetails/UserRoles')); -const UserSettings = lazy(async () => import('@/pages/UserDetails/UserSettings')); -const Users = lazy(async () => import('@/pages/Users')); +const AuditLogDetails = safeLazy(async () => import('@/pages/AuditLogDetails')); +const UserDetails = safeLazy(async () => import('@/pages/UserDetails')); +const UserLogs = safeLazy(async () => import('@/pages/UserDetails/UserLogs')); +const UserOrganizations = safeLazy(async () => import('@/pages/UserDetails/UserOrganizations')); +const UserRoles = safeLazy(async () => import('@/pages/UserDetails/UserRoles')); +const UserSettings = safeLazy(async () => import('@/pages/UserDetails/UserSettings')); +const Users = safeLazy(async () => import('@/pages/Users')); export const users: RouteObject = { path: 'users', diff --git a/packages/console/src/hooks/use-console-routes/routes/webhooks.tsx b/packages/console/src/hooks/use-console-routes/routes/webhooks.tsx index e9823187911..2cc47ee4687 100644 --- a/packages/console/src/hooks/use-console-routes/routes/webhooks.tsx +++ b/packages/console/src/hooks/use-console-routes/routes/webhooks.tsx @@ -1,13 +1,13 @@ -import { lazy } from 'react'; import { Navigate, type RouteObject } from 'react-router-dom'; +import { safeLazy } from 'react-safe-lazy'; import { WebhookDetailsTabs } from '@/consts'; -const WebhookDetails = lazy(async () => import('@/pages/WebhookDetails')); -const AuditLogDetails = lazy(async () => import('@/pages/AuditLogDetails')); -const WebhookSettings = lazy(async () => import('@/pages/WebhookDetails/WebhookSettings')); -const WebhookLogs = lazy(async () => import('@/pages/WebhookDetails/WebhookLogs')); -const Webhooks = lazy(async () => import('@/pages/Webhooks')); +const WebhookDetails = safeLazy(async () => import('@/pages/WebhookDetails')); +const AuditLogDetails = safeLazy(async () => import('@/pages/AuditLogDetails')); +const WebhookSettings = safeLazy(async () => import('@/pages/WebhookDetails/WebhookSettings')); +const WebhookLogs = safeLazy(async () => import('@/pages/WebhookDetails/WebhookLogs')); +const Webhooks = safeLazy(async () => import('@/pages/Webhooks')); export const webhooks: RouteObject = { path: 'webhooks', diff --git a/packages/integration-tests/src/tests/console/error-handling.test.ts b/packages/integration-tests/src/tests/console/error-handling.test.ts new file mode 100644 index 00000000000..a8614686ce4 --- /dev/null +++ b/packages/integration-tests/src/tests/console/error-handling.test.ts @@ -0,0 +1,46 @@ +import { appendPath } from '@silverhand/essentials'; + +import ExpectConsole from '#src/ui-helpers/expect-console.js'; +import { Trace } from '#src/ui-helpers/trace.js'; +import { devFeatureTest } from '#src/utils.js'; + +describe('error handling', () => { + const trace = new Trace(); + + devFeatureTest.it('should handle dynamic import errors', async () => { + const expectConsole = new ExpectConsole(await browser.newPage()); + const path = appendPath(expectConsole.options.endpoint, 'console/__internal__/import-error'); + + trace.reset(expectConsole.page); + await trace.start(); + await expectConsole.navigateTo(path); + await trace.stop(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any + const traceData: { traceEvents: any[] } = await trace.read(); + + const documentLoads = traceData.traceEvents.filter((item) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const data = item?.args?.data ?? {}; + return ( + data.resourceType === 'Document' && data.requestMethod === 'GET' && data.url === path.href + ); + }); + + // Reloaded once + expect(documentLoads).toHaveLength(2); + + // Show the error message + await Promise.all([ + expectConsole.toMatchElement('label', { + text: 'Oops! Something went wrong.', + }), + expectConsole.toMatchElement('span', { + text: 'Failed to fetch dynamically imported module', + }), + expectConsole.toMatchElement('button', { + text: 'Try again', + }), + ]); + }); +}); diff --git a/packages/integration-tests/src/tests/experience/server-side-rendering.test.ts b/packages/integration-tests/src/tests/experience/server-side-rendering.test.ts index 265c76713eb..e41ef12b355 100644 --- a/packages/integration-tests/src/tests/experience/server-side-rendering.test.ts +++ b/packages/integration-tests/src/tests/experience/server-side-rendering.test.ts @@ -1,14 +1,10 @@ -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; - import { demoAppApplicationId, fullSignInExperienceGuard } from '@logto/schemas'; -import { type Page } from 'puppeteer'; import { z } from 'zod'; import { demoAppUrl } from '#src/constants.js'; import { OrganizationApiTest } from '#src/helpers/organization.js'; import ExpectExperience from '#src/ui-helpers/expect-experience.js'; +import { Trace } from '#src/ui-helpers/trace.js'; const ssrDataGuard = z.object({ signInExperience: z.object({ @@ -22,53 +18,6 @@ const ssrDataGuard = z.object({ }), }); -class Trace { - protected tracePath?: string; - - constructor(protected page?: Page) {} - - async start() { - if (this.tracePath) { - throw new Error('Trace already started'); - } - - if (!this.page) { - throw new Error('Page not set'); - } - - const traceDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'trace-')); - this.tracePath = path.join(traceDirectory, 'trace.json'); - await this.page.tracing.start({ path: this.tracePath, categories: ['devtools.timeline'] }); - } - - async stop() { - if (!this.page) { - throw new Error('Page not set'); - } - - return this.page.tracing.stop(); - } - - async read() { - if (!this.tracePath) { - throw new Error('Trace not started'); - } - - return JSON.parse(await fs.readFile(this.tracePath, 'utf8')); - } - - reset(page: Page) { - this.page = page; - this.tracePath = undefined; - } - - async cleanup() { - if (this.tracePath) { - await fs.unlink(this.tracePath); - } - } -} - describe('server-side rendering', () => { const trace = new Trace(); const expectTraceNotToHaveWellKnownEndpoints = async () => { diff --git a/packages/integration-tests/src/ui-helpers/trace.ts b/packages/integration-tests/src/ui-helpers/trace.ts new file mode 100644 index 00000000000..fc37aeda5ed --- /dev/null +++ b/packages/integration-tests/src/ui-helpers/trace.ts @@ -0,0 +1,54 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { type Page } from 'puppeteer'; + +export class Trace { + protected tracePath?: string; + + constructor(protected page?: Page) {} + + async start() { + if (this.tracePath) { + throw new Error('Trace already started'); + } + + if (!this.page) { + throw new Error('Page not set'); + } + + const traceDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'trace-')); + this.tracePath = path.join(traceDirectory, 'trace.json'); + await this.page.tracing.start({ path: this.tracePath, categories: ['devtools.timeline'] }); + } + + async stop() { + if (!this.page) { + throw new Error('Page not set'); + } + + console.log('Trace captured at', this.tracePath); + return this.page.tracing.stop(); + } + + async read() { + if (!this.tracePath) { + throw new Error('Trace not started'); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return JSON.parse(await fs.readFile(this.tracePath, 'utf8')); + } + + reset(page: Page) { + this.page = page; + this.tracePath = undefined; + } + + async cleanup() { + if (this.tracePath) { + await fs.unlink(this.tracePath); + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86b76b1e100..d46ec30669d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3100,6 +3100,9 @@ importers: react-router-dom: specifier: ^6.25.1 version: 6.25.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-safe-lazy: + specifier: ^0.1.0 + version: 0.1.0(react@18.3.1) react-syntax-highlighter: specifier: ^15.5.0 version: 15.5.0(react@18.3.1) @@ -11700,6 +11703,11 @@ packages: peerDependencies: react: '>=16.8' + react-safe-lazy@0.1.0: + resolution: {integrity: sha512-CZSaQHlNVG8OuSRaLkBGe5cP+qjZtW+fJA/PRNTyCIVkH3F7GQO0aBu+jIrm2PFrTVs4xjSvuDFVyzlX6OL0oQ==} + peerDependencies: + react: ^18.0.0 + react-side-effect@2.1.2: resolution: {integrity: sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==} peerDependencies: @@ -23042,6 +23050,10 @@ snapshots: '@remix-run/router': 1.18.0 react: 18.3.1 + react-safe-lazy@0.1.0(react@18.3.1): + dependencies: + react: 18.3.1 + react-side-effect@2.1.2(react@18.3.1): dependencies: react: 18.3.1