Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion routes/dashboard/stage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function Dashboard() {

const [ gridSettings, setGridSettings ] = useDashboardGridSettings();

const widgetTypes = useWidgetTypes();
const [ widgetTypes, isResolving ] = useWidgetTypes();

const [ editMode, setEditMode ] = useState( false );

Expand Down Expand Up @@ -51,6 +51,7 @@ function Dashboard() {
return (
<WidgetDashboard
widgetTypes={ widgetTypes }
isResolvingWidgetTypes={ isResolving }
layout={ layout }
onLayoutChange={ handleLayoutChange }
onLayoutReset={ resetLayout }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@
margin-block-end: 0;
}

.loading {
.loading,
.unavailable {
height: 100%;
}

Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { ReactNode } from 'react';
/**
* WordPress dependencies
*/
import { Icon as WCIcon, Spinner } from '@wordpress/components';

@simison simison May 21, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Icon from @wordpress/ui was already done in #78440, but then I accidentally reverted it in rebase of another PR.

So this just returns it back to the previous state; could do a separate PR, but it seems fine here too.

import { Spinner } from '@wordpress/components';
import {
Component,
Suspense,
Expand All @@ -16,9 +16,8 @@ import {
useMemo,
} from '@wordpress/element';
import { __ } from '@wordpress/i18n';
// Dashboard is still experimental.
// eslint-disable-next-line @wordpress/use-recommended-components
import { Card, Stack, Notice, VisuallyHidden } from '@wordpress/ui';
import { plugins } from '@wordpress/icons';
import { Card, Icon, Stack, Notice, Text, VisuallyHidden } from '@wordpress/ui';

/**
* Internal dependencies
Expand Down Expand Up @@ -69,6 +68,37 @@ function LoadingOverlay() {
);
}

interface UnavailableWidgetProps {
widgetTypeName: string;
}

function UnavailableWidget( { widgetTypeName }: UnavailableWidgetProps ) {
return (
<>
<Card.Header>
<span
className={ styles.widgetChromeHeaderIcon }
aria-hidden="true"
>
<Icon icon={ plugins } />
</span>
</Card.Header>
<Card.Content className={ styles.widgetChromeContent }>
<Stack
direction="column"
justify="center"
align="center"
gap="md"
className={ styles.unavailable }
>
<Text>{ __( 'Widget is no longer available.' ) }</Text>
<Text render={ <code /> }>{ widgetTypeName }</Text>
</Stack>
</Card.Content>
</>
);
}

interface HeaderProps {
titleId: string;
widgetType: WidgetType;
Expand All @@ -87,7 +117,7 @@ function Header( { titleId, widgetType }: HeaderProps ) {
className={ styles.widgetChromeHeaderIcon }
aria-hidden="true"
>
<WCIcon icon={ widgetType.icon } />
<Icon icon={ widgetType.icon } />

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

</span>
) }
<Card.Title id={ titleId } render={ <h3 /> }>
Expand Down Expand Up @@ -119,7 +149,8 @@ export interface WidgetChromeProps {
*/
export const WidgetChrome = forwardRef< HTMLDivElement, WidgetChromeProps >(
function WidgetChrome( { widget, index, className }, ref ) {
const { widgetTypes, editMode } = useDashboardInternalContext();
const { widgetTypes, isResolvingWidgetTypes, editMode } =
useDashboardInternalContext();
const widgetType = widgetTypes.find( ( t ) => t.name === widget.type );
const titleId = useId();

Expand All @@ -133,7 +164,38 @@ export const WidgetChrome = forwardRef< HTMLDivElement, WidgetChromeProps >(
);

if ( ! widgetType ) {
return null;
if ( isResolvingWidgetTypes ) {
return (
<WidgetContextProvider value={ contextValue }>
<Card.Root
render={ <section /> }
ref={ ref }
className={ clsx( styles.widgetChrome, className ) }
aria-busy="true"
aria-label={ __( 'Loading' ) }
>
<Card.Content
className={ styles.widgetChromeContent }
>
<LoadingOverlay />
</Card.Content>
</Card.Root>
</WidgetContextProvider>
);
}

return (
<WidgetContextProvider value={ contextValue }>
<Card.Root
render={ <section /> }
ref={ ref }
className={ clsx( styles.widgetChrome, className ) }
aria-label={ __( 'Missing widget' ) }
>
<UnavailableWidget widgetTypeName={ widget.type } />
</Card.Root>
</WidgetContextProvider>
);
}

// `presentation` encodes two independent axes. `full-bleed` hides
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ function canonicalize( layout: DashboardWidget[] ): DashboardWidget[] {
*/
interface InternalDashboardContextValue {
widgetTypes: WidgetType[];
isResolvingWidgetTypes: boolean;
layout: DashboardWidget[];
onLayoutChange: ( layout: DashboardWidget[] ) => void;
onLayoutReset?: () => void;
Expand Down Expand Up @@ -168,6 +169,11 @@ interface ProviderProps {
*/
widgetTypes: WidgetType[];

/**
* When true, widget types are still loading.
*/
isResolvingWidgetTypes?: boolean;

/**
* Committed layout.
*/
Expand Down Expand Up @@ -239,6 +245,7 @@ interface ProviderProps {
*/
export function WidgetDashboardProvider( {
widgetTypes,
isResolvingWidgetTypes = false,
layout: committedLayout,
onLayoutChange,
onLayoutReset,
Expand Down Expand Up @@ -366,6 +373,7 @@ export function WidgetDashboardProvider( {
const value = useMemo< InternalDashboardContextValue >(
() => ( {
widgetTypes,
isResolvingWidgetTypes,
layout: stagingLayout,
onLayoutChange: setStagingLayout,
onLayoutReset,
Expand All @@ -383,6 +391,7 @@ export function WidgetDashboardProvider( {
} ),
[
widgetTypes,
isResolvingWidgetTypes,
stagingLayout,
onLayoutReset,
stagingGridSettings,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,32 @@ describe( 'WidgetDashboard', () => {
} );
} );

it( 'renders nothing for an unknown widget type (no crash)', () => {
it( 'shows a loading placeholder while widget types are resolving', () => {
render(
<WidgetDashboard
layout={ [
{
uuid: 'w1',
type: 'test/greet',
placement: { width: 1, height: 1 },
},
] }
onLayoutChange={ () => {} }
widgetTypes={ [] }
isResolvingWidgetTypes
resolveWidgetModule={ resolveWidgetModule }
/>
);

expect(
screen.getByRole( 'region', { name: 'Loading' } )
).toBeInTheDocument();
expect(
screen.queryByText( 'Widget is no longer available.' )
).not.toBeInTheDocument();
} );

it( 'shows an unavailable placeholder for an unknown widget type (no crash)', () => {
render(
<WidgetDashboard
layout={ [
Expand All @@ -148,6 +173,16 @@ describe( 'WidgetDashboard', () => {
/>
);
expect( screen.queryByTestId( 'greeting' ) ).not.toBeInTheDocument();
expect(
screen.getByRole( 'region', { name: 'Missing widget' } )
).toBeInTheDocument();
expect(
screen.getByText( 'Widget is no longer available.' )
).toBeInTheDocument();
expect( screen.getByText( 'does/not-exist' ) ).toBeInTheDocument();
expect(
screen.queryByRole( 'heading', { level: 3 } )
).not.toBeInTheDocument();
} );

it( 'renders the NoWidgetsState compound when layout is empty', () => {
Expand Down
6 changes: 6 additions & 0 deletions routes/dashboard/widget-dashboard/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,12 @@ export interface WidgetDashboardProps {
*/
widgetTypes: WidgetType[];

/**
* When true, widget types are still loading. Instances whose type is
* not yet in `widgetTypes` show a loading state instead of missing.
*/
isResolvingWidgetTypes?: boolean;

/**
* Whether the dashboard is in edit mode (enables drag/resize).
*/
Expand Down
2 changes: 2 additions & 0 deletions routes/dashboard/widget-dashboard/widget-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const WidgetDashboard = Object.assign(
onLayoutChange,
onLayoutReset,
widgetTypes,
isResolvingWidgetTypes,
editMode,
onEditChange,
resolveWidgetModule,
Expand All @@ -65,6 +66,7 @@ export const WidgetDashboard = Object.assign(
onLayoutChange={ onLayoutChange }
onLayoutReset={ onLayoutReset }
widgetTypes={ widgetTypes }
isResolvingWidgetTypes={ isResolvingWidgetTypes }
editMode={ editMode }
onEditChange={ onEditChange }
resolveWidgetModule={ resolveWidgetModule }
Expand Down
2 changes: 1 addition & 1 deletion routes/dashboard/widget-types/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Public surface for widget type discovery in the dashboard route.

## What this module exposes

`useWidgetTypes()` returns the `WidgetType[]` available on the current page.
`useWidgetTypes()` returns `[ widgetTypes, isResolvingWidgetTypes ]`: the `WidgetType[]` available on the current page, plus a flag that is true while those types are still resolving.

`WidgetType`, `WidgetTypeMetadata`, `WidgetName` TypeScript types.

Expand Down
29 changes: 24 additions & 5 deletions routes/dashboard/widget-types/hooks/use-widget-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ interface WidgetModuleRecord {
presentation?: WidgetTypeMetadata[ 'presentation' ] | null;
}

/**
* Tuple of `[ widgetTypes, isResolvingWidgetTypes ]`.
*
* The boolean is true while widget-module records or their metadata imports
* have not finished resolving. Layout instances must not be treated as missing
* until it is false.
*/
export type UseWidgetTypesResult = readonly [ WidgetType[], boolean ];

/**
* Returns the registered widget types, with each record's metadata
* resolved from its `widget_module` script module.
Expand All @@ -50,13 +59,12 @@ interface WidgetModuleRecord {
* which fetches `/wp/v2/widget-modules` on first selector resolution.
* For each record this hook dynamically imports `widget_module` and
* merges the module's default export with the runtime fields (`name`,
* `renderModule`). Until the imports resolve the hook returns an empty
* array.
* `renderModule`).
*
* Consumers do not register or dispatch anything; the data layer owns
* caching and invalidation.
*/
export function useWidgetTypes(): WidgetType[] {
export function useWidgetTypes(): UseWidgetTypesResult {
const records = useSelect(
( select ) =>
select( coreStore ).getEntityRecords( 'root', 'widgetModule' ) as
Expand All @@ -66,13 +74,23 @@ export function useWidgetTypes(): WidgetType[] {
);

const [ widgetTypes, setWidgetTypes ] = useState< WidgetType[] >( [] );
const [ isResolvingWidgetTypes, setIsResolvingWidgetTypes ] =
useState( true );

useEffect( () => {
if ( ! records ) {
if ( records === null ) {
setIsResolvingWidgetTypes( true );
return;
}

if ( records.length === 0 ) {
setWidgetTypes( [] );
setIsResolvingWidgetTypes( false );
return;
}

let cancelled = false;
setIsResolvingWidgetTypes( true );

Promise.all(
records.map( async ( record ) => {
Expand Down Expand Up @@ -109,12 +127,13 @@ export function useWidgetTypes(): WidgetType[] {
setWidgetTypes(
results.filter( ( t ): t is WidgetType => t !== null )
);
setIsResolvingWidgetTypes( false );
} );

return () => {
cancelled = true;
};
}, [ records ] );

return widgetTypes;
return [ widgetTypes, isResolvingWidgetTypes ];
}
1 change: 1 addition & 0 deletions routes/dashboard/widget-types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export type {
WidgetType,
WidgetRenderProps,
} from './types';
export type { UseWidgetTypesResult } from './hooks/use-widget-types';
5 changes: 5 additions & 0 deletions tools/eslint/suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -1660,6 +1660,11 @@
"count": 3
}
},
"routes/dashboard/widget-dashboard/components/widget-chrome/widget-chrome.tsx": {
"@wordpress/use-recommended-components": {
"count": 1
}
},
"routes/experiments-home/stage.tsx": {
"@wordpress/no-non-module-stylesheet-imports": {
"count": 1
Expand Down
Loading