Commit 3e3ad05a440 for woocommerce

commit 3e3ad05a44046215cba1c46f34928b2e64801e3a
Author: Miroslav Mitev <m1r0@users.noreply.github.com>
Date:   Tue Aug 25 15:26:43 2026 +0300

    Keep loosely typed dashboard sections from the defaults filter reachable (#67956)

    * Keep loosely typed default dashboard sections reachable

    `getDefaultSections()` ran the stored preference's `deleteUnusableFields` over
    the entries returned by `woocommerce_dashboard_default_sections`. Dropping a
    field works for a stored section because a default backs it up, but nothing
    sits behind a default, so an extension registering `isVisible: 1` ended up with
    `isVisible === undefined`. The render gate is truthiness and "Add more sections"
    filters `=== false`, so the section was neither shown nor offered, with no way
    for the merchant to bring it back.

    Defaults now convert an unusable field instead of dropping it: `hiddenBlocks`
    falls back to an empty list, `isVisible` to the truthiness the render gate has
    always used, and a numeric `title` to the string it was rendered as. A finite
    number is accepted as a section key again, since the key is matched by strict
    equality and round trips through the stored JSON.

    An entry with no component is still dropped, since rendering one takes the whole
    dashboard down.

    * Render a section icon only when it is a React element

    `Icon` clones the icon it is handed, so a section registered through
    `woocommerce_dashboard_default_sections` without one threw where the merchant
    goes to bring the section back, taking the whole dashboard down. Falsy values
    are only half of it: a dashicon name string or a plain object fails the same
    way, one step later, on an invalid element type.

    The icon is decoration, so a section that cannot provide a usable one is still
    offered under "Add more sections", just without it.

diff --git a/plugins/woocommerce/client/admin/client/dashboard/customizable.js b/plugins/woocommerce/client/admin/client/dashboard/customizable.js
index bb749d427ac..4c381b757ec 100644
--- a/plugins/woocommerce/client/admin/client/dashboard/customizable.js
+++ b/plugins/woocommerce/client/admin/client/dashboard/customizable.js
@@ -2,7 +2,7 @@
  * External dependencies
  */
 import { __, sprintf } from '@wordpress/i18n';
-import { useEffect, useMemo, useRef } from '@wordpress/element';
+import { isValidElement, useEffect, useMemo, useRef } from '@wordpress/element';
 import { compose } from '@wordpress/compose';
 import { partial } from 'lodash';
 import { Dropdown, Button } from '@wordpress/components';
@@ -42,9 +42,11 @@ const DASHBOARD_FILTERS_FILTER = 'woocommerce_admin_dashboard_filters';
 const filters = applyFilters( DASHBOARD_FILTERS_FILTER, [] );

 /**
- * A stored section is only usable when it carries the `key` that ties it back to
- * a default section. Corrupted `dashboard_sections` preferences have been seen
- * holding `null` entries, which used to crash the whole dashboard.
+ * A section is only usable when it carries the `key` that ties it back to a
+ * default section. Corrupted `dashboard_sections` preferences have been seen
+ * holding `null` entries, which used to crash the whole dashboard. The key is
+ * matched by strict equality and round trips through the stored JSON, so a
+ * string and a finite number both tie a section back to its default.
  *
  * @param {*} section Entry of the stored `dashboard_sections` preference.
  * @return {boolean} Whether the entry can be merged with a default section.
@@ -52,7 +54,7 @@ const filters = applyFilters( DASHBOARD_FILTERS_FILTER, [] );
 const isValidSection = ( section ) =>
 	!! section &&
 	typeof section === 'object' &&
-	typeof section.key === 'string';
+	( typeof section.key === 'string' || Number.isFinite( section.key ) );

 /**
  * Stored fields that take the dashboard down, or make a section unreachable,
@@ -72,6 +74,23 @@ const FIELD_CHECKS = {
 	title: ( value ) => typeof value === 'string',
 };

+/**
+ * How to read a field a default section holds the wrong type for. Nothing sits
+ * behind a default to patch it up from, so dropping the field would leave it
+ * `undefined`: every section component dereferences `hiddenBlocks`, and an
+ * `undefined` `isVisible` hides the section without listing it under "Add more
+ * sections", which is the one state a merchant cannot get out of. The dashboard
+ * has always rendered any truthy `isVisible` and printed any numeric `title`, so
+ * the value is converted instead of dropped.
+ *
+ * @type {Object.<string, function(*): *>}
+ */
+const FIELD_FALLBACKS = {
+	hiddenBlocks: () => [],
+	isVisible: ( value ) => !! value,
+	title: ( value ) => ( typeof value === 'number' ? String( value ) : '' ),
+};
+
 /**
  * Whether an entry of the stored preference carries usable values for the
  * fields the dashboard dereferences. A missing field is fine, the default
@@ -166,15 +185,35 @@ const toUsableSection = ( section ) =>
 const isUsableDefaultSection = ( section ) =>
 	isValidSection( section ) && !! section.component;

+/**
+ * A default section holding a value the dashboard can read for every field it
+ * dereferences. Dropping the field is what the stored preference does, and it
+ * only works there because a default backs it up. A default has nothing behind
+ * it, so its fields are converted rather than dropped.
+ *
+ * @param {section} section Entry returned by the default sections filter.
+ * @return {section} Copy of the section, holding only usable values.
+ */
+const toUsableDefaultSection = ( section ) => {
+	const usable = { ...section };
+
+	Object.entries( FIELD_CHECKS ).forEach( ( [ field, isUsable ] ) => {
+		if ( ! isUsable( usable[ field ] ) ) {
+			usable[ field ] = FIELD_FALLBACKS[ field ]( usable[ field ] );
+		}
+	} );
+
+	return usable;
+};
+
 /**
  * Copy of the default sections, throwing a descriptive error when the
  * `woocommerce_dashboard_default_sections` filter returned something unusable.
- * The filter is a third party surface, so its entries get the same treatment as
- * the stored ones: an entry no section can be built from is dropped, and a
- * corrupted field is dropped so it cannot overwrite a valid stored value.
- * Nothing sits behind a default to patch a field up from, except `hiddenBlocks`
- * which every section component dereferences, so that one falls back to an
- * empty list.
+ * The filter is a third party surface, so an entry no section can be built from
+ * is dropped. Everything else is kept: the filter is a released extension point
+ * that never enforced the documented types, and the dashboard is the last thing
+ * standing between an extension's section and the merchant, so a field it
+ * cannot read is converted instead of costing them the section.
  *
  * @return {Array.<section>} Default sections.
  */
@@ -187,15 +226,7 @@ const getDefaultSections = () => {

 	return defaultSections
 		.filter( isUsableDefaultSection )
-		.map( ( section ) => {
-			const usable = deleteUnusableFields( { ...section } );
-
-			if ( ! Array.isArray( usable.hiddenBlocks ) ) {
-				usable.hiddenBlocks = [];
-			}
-
-			return usable;
-		} );
+		.map( toUsableDefaultSection );
 };

 export const mergeSectionsWithDefaults = ( prefSections ) => {
@@ -439,11 +470,15 @@ const CustomizableDashboard = ( { defaultDateRange, path, query } ) => {
 											section.title
 										) }
 									>
-										<Icon
-											className={ section.key + '__icon' }
-											icon={ section.icon }
-											size={ 30 }
-										/>
+										{ isValidElement( section.icon ) && (
+											<Icon
+												className={
+													section.key + '__icon'
+												}
+												icon={ section.icon }
+												size={ 30 }
+											/>
+										) }
 										<span className="woocommerce-dashboard-section__add-more-btn-title">
 											{ section.title }
 										</span>
diff --git a/plugins/woocommerce/client/admin/client/dashboard/test/customizable.js b/plugins/woocommerce/client/admin/client/dashboard/test/customizable.js
index 69e4581e061..b3965bbcd8f 100644
--- a/plugins/woocommerce/client/admin/client/dashboard/test/customizable.js
+++ b/plugins/woocommerce/client/admin/client/dashboard/test/customizable.js
@@ -196,14 +196,71 @@ describe( 'mergeSectionsWithDefaults', () => {
 		expect( charts.hiddenBlocks ).toEqual( [] );
 	} );

-	it( 'does not fall back to a corrupted default field', () => {
+	it( 'falls back to a readable value for a corrupted default title', () => {
+		// The title is rendered as a React child by every section header.
 		mockDefaultSections = [ { ...DEFAULT_SECTIONS[ 1 ], title: {} } ];

 		const [ charts ] = mergeSectionsWithDefaults( [
 			{ key: 'charts', title: { rendered: 'My charts' } },
 		] );

-		expect( charts.title ).toBeUndefined();
+		expect( charts.title ).toBe( '' );
+	} );
+
+	it( 'keeps a default section that registers a truthy isVisible', () => {
+		// The filter never enforced a boolean and the dashboard rendered any
+		// truthy value, so dropping it would make the section unreachable.
+		mockDefaultSections = [
+			{ ...DEFAULT_SECTIONS[ 0 ], isVisible: 1 },
+			{ ...DEFAULT_SECTIONS[ 1 ], isVisible: 'yes' },
+		];
+
+		const sections = mergeSectionsWithDefaults( undefined );
+
+		expect( sections.map( ( section ) => section.isVisible ) ).toEqual( [
+			true,
+			true,
+		] );
+	} );
+
+	it( 'offers a default section that registers a falsy isVisible', () => {
+		// `undefined` is neither visible nor listed under "Add more sections".
+		mockDefaultSections = [
+			{ ...DEFAULT_SECTIONS[ 0 ], isVisible: 0 },
+			{ ...DEFAULT_SECTIONS[ 1 ], isVisible: undefined },
+		];
+
+		const sections = mergeSectionsWithDefaults( undefined );
+
+		expect( sections.map( ( section ) => section.isVisible ) ).toEqual( [
+			false,
+			false,
+		] );
+	} );
+
+	it( 'keeps a default section that registers a numeric title', () => {
+		// React prints a number, so the header rendered it before.
+		mockDefaultSections = [ { ...DEFAULT_SECTIONS[ 1 ], title: 2026 } ];
+
+		const [ charts ] = mergeSectionsWithDefaults( undefined );
+
+		expect( charts.title ).toBe( '2026' );
+	} );
+
+	it( 'keeps a default section keyed by a number', () => {
+		// The key is matched by strict equality and round trips through the
+		// stored JSON, so a number ties the section back to its default.
+		mockDefaultSections = [ { ...DEFAULT_SECTIONS[ 1 ], key: 42 } ];
+
+		const sections = mergeSectionsWithDefaults( [
+			{ key: 42, title: 'My charts' },
+		] );
+
+		expect( sections ).toHaveLength( 1 );
+		expect( sections[ 0 ] ).toMatchObject( {
+			key: 42,
+			title: 'My charts',
+		} );
 	} );
 } );

@@ -236,6 +293,67 @@ describe( 'CustomizableDashboard', () => {
 		expect( getByText( 'Charts' ) ).toBeInTheDocument();
 	} );

+	it( 'renders a registered section that is visible by a truthy value', () => {
+		// An extension section registered the way the released dashboard
+		// accepted it, on a store that never customized the dashboard.
+		mockDefaultSections = [
+			...DEFAULT_SECTIONS,
+			{
+				key: 'my-extension',
+				component: () => null,
+				title: 'Mine',
+				isVisible: 1,
+				hiddenBlocks: [],
+			},
+		];
+
+		const { getByText } = renderDashboard( undefined );
+
+		expect( getByText( 'Mine' ) ).toBeInTheDocument();
+	} );
+
+	it( 'offers a hidden section the filter registered without an icon', () => {
+		// `Icon` clones the icon it is handed, so anything but a React element
+		// throws where the merchant goes to bring the section back.
+		mockDefaultSections = [
+			...DEFAULT_SECTIONS,
+			{
+				key: 'my-extension',
+				component: () => null,
+				title: 'Mine',
+				isVisible: 0,
+				hiddenBlocks: [],
+			},
+		];
+
+		const { getByTitle } = renderDashboard( undefined );
+		fireEvent.click( getByTitle( 'Add more sections' ) );
+
+		expect( getByTitle( 'Add Mine section' ) ).toBeInTheDocument();
+	} );
+
+	it( 'renders the icon of a hidden section that provides one', () => {
+		mockDefaultSections = [
+			...DEFAULT_SECTIONS,
+			{
+				key: 'my-extension',
+				component: () => null,
+				title: 'Mine',
+				isVisible: false,
+				icon: <svg />,
+				hiddenBlocks: [],
+			},
+		];
+
+		// The dropdown renders in a popover, so it lands outside `container`.
+		const { baseElement, getByTitle } = renderDashboard( undefined );
+		fireEvent.click( getByTitle( 'Add more sections' ) );
+
+		expect(
+			baseElement.querySelector( '.my-extension__icon' )
+		).toBeInTheDocument();
+	} );
+
 	it( 'repairs a corrupted preference once, without the React nodes', () => {
 		const { rerender } = renderDashboard( [ null, null ] );