Add front component skeleton loader (#23261)

Front components (dashboard widget, side panel, settings preview) showed
blank space during their entire load. They now show a shimmering
full-area skeleton continuously, from the lazy chunk load through
metadata fetch, token/SDK wait, and worker boot, until the real UI
mounts.

The skeleton is threaded down as an optional `loadingFallback` prop so
the shared `twenty-front-component-renderer` package stays
dependency-free (react-loading-skeleton stays in twenty-front). The
command-menu headless component opts out by not passing a fallback, so
it stays blank as before.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23261?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-07-24 14:09:16 +02:00
committed by GitHub
parent 3a8f086d15
commit 3ee8fc0973
6 changed files with 62 additions and 8 deletions
@@ -11,7 +11,7 @@ import {
type RemoteReceiver,
RemoteRootRenderer,
} from '@remote-dom/react/host';
import { useState } from 'react';
import { type ReactNode, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { isDefined } from 'twenty-shared/utils';
@@ -35,6 +35,7 @@ type FrontComponentRendererProps = {
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
onError: (error?: Error) => void;
colorScheme: 'light' | 'dark';
loadingFallback?: ReactNode;
};
export const FrontComponentRenderer = ({
@@ -48,6 +49,7 @@ export const FrontComponentRenderer = ({
frontComponentHostCommunicationApi,
onError,
colorScheme,
loadingFallback,
}: FrontComponentRendererProps) => {
const [receiver, setReceiver] = useState<RemoteReceiver | null>(null);
const [thread, setThread] = useState<FrontComponentThread | null>(null);
@@ -55,6 +57,8 @@ export const FrontComponentRenderer = ({
const [isExecutionContextInitialized, setIsExecutionContextInitialized] =
useState(false);
const isReady = isDefined(receiver) && isExecutionContextInitialized;
return (
<>
<FrontComponentWorkerEffect
@@ -100,7 +104,9 @@ export const FrontComponentRenderer = ({
</>
)}
{isDefined(receiver) && isExecutionContextInitialized && (
{!isDefined(error) && !isReady && loadingFallback}
{isReady && (
<ThemeProvider colorScheme={colorScheme}>
<ErrorBoundary
onError={setError}
@@ -10,7 +10,7 @@ import { useGetLogicFunctionHttpUrl } from '@/settings/logic-functions/hooks/use
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { useCallback, useContext, useMemo } from 'react';
import { type ReactNode, useCallback, useContext, useMemo } from 'react';
import { FrontComponentRenderer as SharedFrontComponentRenderer } from 'twenty-front-component-renderer';
import { isDefined } from 'twenty-shared/utils';
import { ThemeContext } from 'twenty-ui/theme-constants';
@@ -25,6 +25,7 @@ type FrontComponentRendererProps = {
frontComponentId: string;
commandMenuItemId?: string;
selectedRecordIds?: string[];
loadingFallback?: ReactNode;
};
type ResolvedFrontComponent = NonNullable<
@@ -35,12 +36,14 @@ type FrontComponentRendererContentProps = {
frontComponent: ResolvedFrontComponent;
commandMenuItemId?: string;
selectedRecordIds?: string[];
loadingFallback?: ReactNode;
};
export const FrontComponentRenderer = ({
frontComponentId,
commandMenuItemId,
selectedRecordIds,
loadingFallback,
}: FrontComponentRendererProps) => {
const { data, loading, error } = useQuery(FindOneFrontComponentDocument, {
variables: { id: frontComponentId },
@@ -55,11 +58,13 @@ export const FrontComponentRenderer = ({
return (
<>
<FrontComponentLoadErrorSnackBarEffect errorMessage={error?.message} />
{loading && loadingFallback}
{!loading && isDefined(frontComponent) && (
<FrontComponentRendererContent
frontComponent={frontComponent}
commandMenuItemId={commandMenuItemId}
selectedRecordIds={selectedRecordIds}
loadingFallback={loadingFallback}
/>
)}
</>
@@ -70,6 +75,7 @@ const FrontComponentRendererContent = ({
frontComponent,
commandMenuItemId,
selectedRecordIds,
loadingFallback,
}: FrontComponentRendererContentProps) => {
const { colorScheme } = useContext(ThemeContext);
const { enqueueErrorSnackBar } = useSnackBar();
@@ -127,6 +133,7 @@ const FrontComponentRendererContent = ({
const applicationVariables = frontComponent.applicationVariables ?? undefined;
const isSdkClientReady = !usesSdkClient || !sdkClientChecksumsLoading;
const isReadyToRender = isDefined(applicationTokenPair) && isSdkClientReady;
return (
<>
@@ -134,7 +141,8 @@ const FrontComponentRendererContent = ({
frontComponentId={frontComponentId}
applicationTokenPair={applicationTokenPair}
/>
{isDefined(applicationTokenPair) && isSdkClientReady && (
{!isReadyToRender && loadingFallback}
{isReadyToRender && (
<FrontComponentRendererProvider frontComponentId={frontComponentId}>
<SharedFrontComponentRenderer
colorScheme={colorScheme}
@@ -151,6 +159,7 @@ const FrontComponentRendererContent = ({
}
applicationVariables={applicationVariables}
onError={handleError}
loadingFallback={loadingFallback}
/>
</FrontComponentRendererProvider>
)}
@@ -0,0 +1,30 @@
import { styled } from '@linaria/react';
import { useContext } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { ThemeContext } from 'twenty-ui/theme-constants';
const StyledContainer = styled.div`
height: 100%;
width: 100%;
& > span {
display: block;
height: 100%;
}
`;
export const FrontComponentSkeletonLoader = () => {
const { theme } = useContext(ThemeContext);
return (
<StyledContainer>
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={theme.border.radius.md}
>
<Skeleton height="100%" />
</SkeletonTheme>
</StyledContainer>
);
};
@@ -3,6 +3,7 @@ import { Suspense, lazy } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { FrontComponentSkeletonLoader } from '@/front-components/components/FrontComponentSkeletonLoader';
import { usePageLayoutContentContext } from '@/page-layout/contexts/PageLayoutContentContext';
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
@@ -57,10 +58,11 @@ export const FrontComponentWidgetRenderer = ({
isCanvasLayout={layoutMode === PageLayoutTabLayoutMode.CANVAS}
isInEditMode={isPageLayoutInEditMode}
>
<Suspense fallback={null}>
<Suspense fallback={<FrontComponentSkeletonLoader />}>
<FrontComponentRenderer
frontComponentId={frontComponentId}
selectedRecordIds={selectedRecordIds}
loadingFallback={<FrontComponentSkeletonLoader />}
/>
</Suspense>
</StyledContainer>
@@ -1,5 +1,6 @@
import { Suspense, lazy } from 'react';
import { FrontComponentSkeletonLoader } from '@/front-components/components/FrontComponentSkeletonLoader';
import { viewableFrontComponentIdComponentState } from '@/side-panel/pages/front-component/states/viewableFrontComponentIdComponentState';
import { viewableFrontComponentRecordContextComponentState } from '@/side-panel/pages/front-component/states/viewableFrontComponentRecordContextComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
@@ -31,10 +32,11 @@ export const SidePanelFrontComponentPage = () => {
: undefined;
return (
<Suspense fallback={null}>
<Suspense fallback={<FrontComponentSkeletonLoader />}>
<FrontComponentRenderer
frontComponentId={viewableFrontComponentId}
selectedRecordIds={selectedRecordIds}
loadingFallback={<FrontComponentSkeletonLoader />}
/>
</Suspense>
);
@@ -4,6 +4,8 @@ import { Suspense, lazy } from 'react';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { FrontComponentSkeletonLoader } from '@/front-components/components/FrontComponentSkeletonLoader';
const FrontComponentRenderer = lazy(() =>
import('@/front-components/components/FrontComponentRenderer').then(
(module) => ({ default: module.FrontComponentRenderer }),
@@ -64,8 +66,11 @@ export const SettingsApplicationFrontComponentPreviewTab = ({
</StyledHeadlessNotice>
) : (
<StyledRendererContainer>
<Suspense fallback={null}>
<FrontComponentRenderer frontComponentId={frontComponentId} />
<Suspense fallback={<FrontComponentSkeletonLoader />}>
<FrontComponentRenderer
frontComponentId={frontComponentId}
loadingFallback={<FrontComponentSkeletonLoader />}
/>
</Suspense>
</StyledRendererContainer>
)}