diff --git a/src/wp-includes/class-wp-icons-registry.php b/src/wp-includes/class-wp-icons-registry.php
index 2e306bec77e53..ceec97b50e665 100644
--- a/src/wp-includes/class-wp-icons-registry.php
+++ b/src/wp-includes/class-wp-icons-registry.php
@@ -111,6 +111,34 @@ protected function register( $icon_name, $icon_properties ) {
return false;
}
+ if ( preg_match( '/[A-Z]/', $icon_name ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ __( 'Icon names must not contain uppercase characters.' ),
+ '7.1.0'
+ );
+ return false;
+ }
+
+ $name_matcher = '/^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/';
+ if ( ! preg_match( $name_matcher, $icon_name ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ __( 'Icon names must contain a namespace prefix. Example: my-plugin/my-custom-icon' ),
+ '7.1.0'
+ );
+ return false;
+ }
+
+ if ( $this->is_registered( $icon_name ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ __( 'Icon is already registered.' ),
+ '7.1.0'
+ );
+ return false;
+ }
+
$allowed_keys = array_fill_keys( array( 'label', 'content', 'filePath' ), 1 );
foreach ( array_keys( $icon_properties ) as $key ) {
if ( ! array_key_exists( $key, $allowed_keys ) ) {
diff --git a/tests/phpunit/tests/icons/wpIconsRegistry.php b/tests/phpunit/tests/icons/wpIconsRegistry.php
new file mode 100644
index 0000000000000..5cfc6df48101e
--- /dev/null
+++ b/tests/phpunit/tests/icons/wpIconsRegistry.php
@@ -0,0 +1,118 @@
+registry = WP_Icons_Registry::get_instance();
+ }
+
+ public function tear_down() {
+ $instance_property = new ReflectionProperty( WP_Icons_Registry::class, 'instance' );
+
+ /*
+ * ReflectionProperty::setAccessible is:
+ * - redundant as of 8.1.0, which made all properties accessible
+ * - deprecated as of 8.5.0
+ * - needed until 8.1.0, as property `instance` is private
+ */
+ if ( PHP_VERSION_ID < 80100 ) {
+ $instance_property->setAccessible( true );
+ }
+
+ $instance_property->setValue( null, null );
+
+ $this->registry = null;
+ parent::tear_down();
+ }
+
+ /**
+ * Invokes WP_Icons_Registry::register despite it being private
+ *
+ * @param string $icon_name Icon name including namespace.
+ * @param array $icon_properties Icon properties (label, content, filePath).
+ * @return bool True if the icon was registered successfully.
+ */
+ private function register( $icon_name, $icon_properties ) {
+ $method = new ReflectionMethod( $this->registry, 'register' );
+
+ /*
+ * ReflectionMethod::setAccessible is:
+ * - redundant as of 8.1.0, which made all properties accessible
+ * - deprecated as of 8.5.0
+ * - needed until 8.1.0, as property `instance` is private
+ */
+ if ( PHP_VERSION_ID < 80100 ) {
+ $method->setAccessible( true );
+ }
+
+ return $method->invoke( $this->registry, $icon_name, $icon_properties );
+ }
+
+ /**
+ * Provides invalid icon names.
+ *
+ * @return array[]
+ */
+ public function data_invalid_icon_names() {
+ return array(
+ 'non-string name' => array( 1 ),
+ 'no namespace' => array( 'plus' ),
+ 'uppercase characters' => array( 'Test/Plus' ),
+ 'invalid characters' => array( 'test/_doing_it_wrong' ),
+ );
+ }
+
+ /**
+ * Should fail to re-register the same icon.
+ *
+ * @expectedIncorrectUsage WP_Icons_Registry::register
+ */
+ public function test_register_icon_twice() {
+ $name = 'test-plugin/duplicate';
+ $settings = array(
+ 'label' => 'Icon',
+ 'content' => '',
+ );
+
+ $result = $this->register( $name, $settings );
+ $this->assertTrue( $result );
+ $result2 = $this->register( $name, $settings );
+ $this->assertFalse( $result2 );
+ }
+
+
+ /**
+ * Should fail to register icon with invalid names.
+ *
+ * @dataProvider data_invalid_icon_names
+ * @expectedIncorrectUsage WP_Icons_Registry::register
+ */
+ public function test_register_invalid_name() {
+ foreach ( $this->data_invalid_icon_names() as $name ) {
+ $settings = array(
+ 'label' => 'Icon',
+ 'content' => '',
+ );
+
+ $result = $this->register( $name, $settings );
+ $this->assertFalse( $result );
+ }
+ }
+}