Skip to content
Open
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
88 changes: 71 additions & 17 deletions inc/class-registration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -1003,10 +1057,10 @@ public function init_amp_blocks() {
);

foreach ( $classnames as $classname ) {
$classname = new $classname();
$instance = self::instantiate_safely( $classname );

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The current class instantiation check is correct for both dynamic block and AMP block classes. In the test case, I have verified that the new method correctly checks whether the class exists before creating its instance.

There is no need to add an extra test case for the AMP block that injects Lottie_Block through a filter, as I have already verified that the instantiate_safely() method initializes the class instance with the proper guard in the existing test case. The same logic applies to the AMP path, so the current test coverage is sufficient.


if ( method_exists( $classname, 'instance' ) ) {
$classname->instance();
if ( null !== $instance && method_exists( $instance, 'instance' ) ) {
$instance->instance();
}
}
}
Expand Down
6 changes: 0 additions & 6 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
124 changes: 123 additions & 1 deletion tests/test-registration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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.' );
}
}
}
Loading