diff --git a/inc/class-registration.php b/inc/class-registration.php index 5df6f6901..b7acfd6e9 100644 --- a/inc/class-registration.php +++ b/inc/class-registration.php @@ -969,26 +969,80 @@ public function register_blocks() { ); } - if ( isset( $dynamic_blocks[ $block ] ) && class_exists( $dynamic_blocks[ $block ] ) ) { - $classname = $dynamic_blocks[ $block ]; - $renderer = new $classname(); - - if ( method_exists( $renderer, 'render' ) ) { - register_block_type_from_metadata( - $metadata_file, - array( - 'render_callback' => array( $renderer, 'render' ), - ) - ); - - continue; - } + $renderer = isset( $dynamic_blocks[ $block ] ) ? self::instantiate_safely( $dynamic_blocks[ $block ] ) : null; + + if ( null !== $renderer && method_exists( $renderer, 'render' ) ) { + register_block_type_from_metadata( + $metadata_file, + array( + 'render_callback' => array( $renderer, 'render' ), + ) + ); + + continue; } register_block_type_from_metadata( $metadata_file ); } } + /** + * Instantiate a class without ever fataling the request. + * + * @param mixed $classname Class name to instantiate. + * @return object|null The instance, or null when it cannot be built. + */ + private static function instantiate_safely( $classname ) { + if ( ! is_string( $classname ) || '' === trim( $classname ) ) { + self::log_skipped_class( $classname, 'is not a class name' ); + + return null; + } + + try { + // An autoloader can throw or fatal on its own; keep it inside the try. + if ( ! class_exists( $classname ) ) { + self::log_skipped_class( $classname, 'could not be loaded' ); + + return null; + } + + $reflection = new \ReflectionClass( $classname ); + + if ( ! $reflection->isInstantiable() ) { + self::log_skipped_class( $classname, 'is not instantiable' ); + + return null; + } + + $constructor = $reflection->getConstructor(); + + if ( null !== $constructor && $constructor->getNumberOfRequiredParameters() > 0 ) { + self::log_skipped_class( $classname, 'requires constructor arguments' ); + + return null; + } + + return $reflection->newInstance(); + } catch ( \Throwable $e ) { + // Covers Error too: a missing dependency inside the constructor. + self::log_skipped_class( $classname, 'threw while being instantiated: ' . $e->getMessage() ); + + return null; + } + } + + /** + * Log a class the plugin had to skip. + * + * @param mixed $classname Class name, or whatever was given in its place. + * @param string $reason Why it was skipped. + * @return void + */ + private static function log_skipped_class( $classname, $reason ) { + error_log( '[Otter Blocks] Skipped ' . ( is_string( $classname ) ? $classname : gettype( $classname ) ) . ': ' . $reason . '.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + } + /** * Initialize AMP blocks. * @@ -1003,10 +1057,10 @@ public function init_amp_blocks() { ); foreach ( $classnames as $classname ) { - $classname = new $classname(); + $instance = self::instantiate_safely( $classname ); - if ( method_exists( $classname, 'instance' ) ) { - $classname->instance(); + if ( null !== $instance && method_exists( $instance, 'instance' ) ) { + $instance->instance(); } } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 07f689482..c1ba7b1a4 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -420,12 +420,6 @@ parameters: count: 1 path: inc/class-pro.php - - - rawMessage: 'Call to function method_exists() with ThemeIsle\GutenbergBlocks\Render\AMP\Circle_Counter_Block|ThemeIsle\GutenbergBlocks\Render\AMP\Lottie_Block|ThemeIsle\GutenbergBlocks\Render\AMP\Slider_Block and ''instance'' will always evaluate to true.' - identifier: function.alreadyNarrowedType - count: 1 - path: inc/class-registration.php - - rawMessage: 'Method ThemeIsle\GutenbergBlocks\Registration::block_categories() has parameter $categories with no value type specified in iterable type array.' identifier: missingType.iterableValue diff --git a/tests/test-registration.php b/tests/test-registration.php index d12b2983c..6ba0709c6 100644 --- a/tests/test-registration.php +++ b/tests/test-registration.php @@ -7,6 +7,73 @@ use ThemeIsle\GutenbergBlocks\Registration; +/** + * Renderer whose constructor touches a class that is absent from the deployed + * artifact: `class_exists()` on the renderer passes, `new` fatals. + */ +class Otter_Renderer_With_Missing_Dependency { + /** + * Constructor. + */ + public function __construct() { + new Otter_Dependency_That_Does_Not_Exist(); + } + + /** + * Render the block. + * + * @param array $attributes Block attributes. + * @return string + */ + public function render( $attributes ) { + return ''; + } +} + +/** + * Renderer that cannot be instantiated: a singleton with a private constructor. + */ +class Otter_Renderer_With_Private_Constructor { + /** + * Constructor. + */ + private function __construct() { + } + + /** + * Render the block. + * + * @param array $attributes Block attributes. + * @return string + */ + public function render( $attributes ) { + return ''; + } +} + +/** + * Renderer whose constructor takes a required argument. + */ +class Otter_Renderer_Requiring_Arguments { + /** + * Constructor. + * + * @param string $required Required dependency. + */ + public function __construct( $required ) { + } + + /** + * Render the block. + * + * @param array $attributes Block attributes. + * @return string + */ + public function render( $attributes ) { + return ''; + } +} + /** * Editor localization tests: the global defaults handed to the editor must * always be an object, or every block's Edit component crashes on null. @@ -87,7 +154,7 @@ public function test_editor_global_defaults_is_object_when_option_is_missing() { * bootstrap, so the plugin's blocks are unregistered first to keep this a * clean registration instead of "already registered" notices. * - * @param string $classname Renderer class to map form-captcha to. + * @param mixed $classname Renderer class to map form-captcha to. * @param string $expected_include_failure Path whose failed include is expected, or empty if no include failure is expected. * @return void */ @@ -244,4 +311,59 @@ public function test_register_blocks_survives_dynamic_renderer_class_whose_file_ $this->assertFalse( class_exists( $class, false ), 'The renderer class must not have been defined.' ); $this->assertCaptchaRegisteredWithoutRenderer(); } + + /** + * The renderer class loads, but its constructor references a class that is + * missing — the reported "class not found" fatal. Registration must fall back + * instead of taking the request down. + */ + public function test_register_blocks_survives_renderer_whose_constructor_hits_a_missing_class() { + $this->register_blocks_with_captcha_renderer( 'Otter_Renderer_With_Missing_Dependency' ); + + $this->assertCaptchaRegisteredWithoutRenderer(); + } + + /** + * A loadable but uninstantiable renderer (abstract class, or a singleton with + * a private constructor) must degrade too. + */ + public function test_register_blocks_survives_uninstantiable_renderer() { + $this->register_blocks_with_captcha_renderer( 'Otter_Renderer_With_Private_Constructor' ); + + $this->assertCaptchaRegisteredWithoutRenderer(); + } + + /** + * A renderer whose constructor requires arguments cannot be built with `new`. + */ + public function test_register_blocks_survives_renderer_requiring_constructor_arguments() { + $this->register_blocks_with_captcha_renderer( 'Otter_Renderer_Requiring_Arguments' ); + + $this->assertCaptchaRegisteredWithoutRenderer(); + } + + /** + * A non-string entry injected through the filter must not reach + * `class_exists()`, which throws a TypeError on those in PHP 8. + */ + public function test_register_blocks_survives_non_string_renderer_entry() { + $this->register_blocks_with_captcha_renderer( array( 'Otter_Renderer_With_Private_Constructor' ) ); + + $this->assertCaptchaRegisteredWithoutRenderer(); + } + + /** + * The AMP list has no filter to inject through, so the guard there is covered + * by running it: it must complete, and every class it ships must still be + * loadable, which is what a stale classmap would break. + */ + public function test_init_amp_blocks_completes_and_ships_loadable_classes() { + ( new Registration() )->init_amp_blocks(); + + foreach ( array( 'Circle_Counter_Block', 'Lottie_Block', 'Slider_Block' ) as $short ) { + $classname = '\\ThemeIsle\\GutenbergBlocks\\Render\\AMP\\' . $short; + + $this->assertTrue( class_exists( $classname ), $classname . ' is registered as an AMP block but cannot be loaded.' ); + } + } }