From 69e64b52330f4c7fcdc74c88211a8a5c4750764d Mon Sep 17 00:00:00 2001 From: Luca Dobrescu Date: Mon, 3 Aug 2026 09:55:47 +0300 Subject: [PATCH 1/2] fix: frontend fatal when a listed class cannot be autoloaded Main::autoload_classes() instantiated every entry of its class list without checking that the class is loadable. On a package with a stale Composer classmap the Atomic Wind entry resolved to nothing and `new $classname()` threw an uncaught Error on `init`, taking down every request (#2954). Skip entries that are not loadable so a packaging or third-party filter problem degrades to a missing feature instead of a site-wide fatal. Co-Authored-By: Claude Opus 5 (1M context) --- inc/class-main.php | 5 + .../mu-plugins/otter-e2e-bootstrap.php | 33 ++++++ .../e2e/blocks/autoloader-resilience.spec.js | 43 ++++++++ src/blocks/test/e2e/playwright.config.js | 5 +- tests/test-main-autoload.php | 102 ++++++++++++++++++ 5 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 src/blocks/test/e2e/blocks/autoloader-resilience.spec.js create mode 100644 tests/test-main-autoload.php diff --git a/inc/class-main.php b/inc/class-main.php index f4288c356..6a34e4f47 100644 --- a/inc/class-main.php +++ b/inc/class-main.php @@ -94,6 +94,11 @@ public function autoload_classes() { $classnames = apply_filters( 'otter_blocks_autoloader', $classnames ); foreach ( $classnames as $classname ) { + // A stale Composer classmap or a third-party filter can list a class that is not loadable; skip it instead of fataling the request. + if ( ! is_string( $classname ) || ! class_exists( $classname ) ) { + continue; + } + $classname = new $classname(); if ( method_exists( $classname, 'instance' ) ) { diff --git a/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php b/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php index 649de761d..963058ec2 100644 --- a/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php +++ b/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php @@ -51,6 +51,8 @@ // Form webhooks registry; retention specs seed a dead-URL webhook to force // a delivery failure with the 'webhook' action. 'themeisle_webhooks_options', + // Scenario flag for the autoloader-resilience spec; see break_otter_autoloader(). + 'otter_e2e_broken_autoloader', 'otter_blocks_logger_flag', 'otter_blocks_logger_data', 'otter_activation_first_save', @@ -107,6 +109,12 @@ */ const WIDGET_SEED_INDEX = 999; +/** + * When truthy, an unloadable class is put at the head of the Otter autoloader list, + * reproducing a stale Composer classmap on a released package (issue #2954). + */ +const BROKEN_AUTOLOADER_OPTION = 'otter_e2e_broken_autoloader'; + /** * Form record post type, mirrored from \ThemeIsle\GutenbergBlocks\Plugins\Form_Submissions. */ @@ -802,6 +810,31 @@ function ( $block_content ) use ( &$saved ) { add_action( 'wp', __NAMESPACE__ . '\\corrupt_pages_around_dynamic_tags' ); +/** + * Put an unloadable class first in the Otter autoloader list when the scenario option is on. + * + * @param array $classnames Classes Otter initializes on `init`. + * @return array + */ +function break_otter_autoloader( $classnames ) { + if ( ! get_option( BROKEN_AUTOLOADER_OPTION, false ) ) { + return $classnames; + } + + // Never break the scenario endpoints themselves, or a spec running against unfixed + // code could not disarm the flag and would poison the rest of the run. + $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; + if ( false !== strpos( $uri, REST_NAMESPACE ) ) { + return $classnames; + } + + array_unshift( $classnames, '\ThemeIsle\GutenbergBlocks\Plugins\Missing_From_Classmap' ); + + return $classnames; +} + +add_filter( 'otter_blocks_autoloader', __NAMESPACE__ . '\\break_otter_autoloader' ); + add_filter( 'pre_wp_mail', __NAMESPACE__ . '\\stub_wp_mail_for_e2e', 10, 2 ); add_filter( 'pre_http_request', __NAMESPACE__ . '\\stub_openai_http_for_e2e', 10, 3 ); diff --git a/src/blocks/test/e2e/blocks/autoloader-resilience.spec.js b/src/blocks/test/e2e/blocks/autoloader-resilience.spec.js new file mode 100644 index 000000000..8fc05d8d2 --- /dev/null +++ b/src/blocks/test/e2e/blocks/autoloader-resilience.spec.js @@ -0,0 +1,43 @@ +/** + * Internal dependencies + */ +import { test, expect } from '../fixtures'; + +/** + * Regression for #2954: a class listed for autoloading that the released package cannot load + * (stale Composer classmap) crashed every request in `Main::autoload_classes()`. + */ +test.describe( 'Autoloader resilience', () => { + test.beforeEach( async({ otterUtils }) => { + await otterUtils.setOptions({ otter_e2e_broken_autoloader: true }); + }); + + test.afterEach( async({ otterUtils }) => { + await otterUtils.setOptions({ otter_e2e_broken_autoloader: false }); + }); + + test( 'frontend survives an unloadable class in the autoload list', async({ page, requestUtils }) => { + const post = await requestUtils.createPost({ + title: 'Autoloader resilience', + content: '', + status: 'publish' + }); + + const response = await page.goto( post.link ); + + expect( response.status() ).toBe( 200 ); + await expect( page.locator( 'text=There has been a critical error' ) ).toBeHidden(); + + // The blocks listed after the unloadable one must still be initialized: + // posts-grid only renders when Registration ran. The grid lists earlier + // posts, which can embed their own grid, so match the first one. + await expect( page.locator( '.wp-block-themeisle-blocks-posts-grid' ).first() ).toBeVisible(); + }); + + test( 'admin survives an unloadable class in the autoload list', async({ page, admin }) => { + await admin.visitAdminPage( 'admin.php?page=otter' ); + + await expect( page.locator( 'text=There has been a critical error' ) ).toBeHidden(); + await expect( page.locator( '#otter' ) ).toBeVisible(); + }); +}); diff --git a/src/blocks/test/e2e/playwright.config.js b/src/blocks/test/e2e/playwright.config.js index 78823c080..79ec41825 100644 --- a/src/blocks/test/e2e/playwright.config.js +++ b/src/blocks/test/e2e/playwright.config.js @@ -60,7 +60,10 @@ const SERIAL_SPECS = [ '**/blocks/atomic-wind-list-view.spec.js', // Switches the active theme and mutates site-wide widget + filesystem state. - '**/blocks/widgets-css-frontend.spec.js' + '**/blocks/widgets-css-frontend.spec.js', + + // Flips a site-wide flag that breaks Otter's autoloader for every request. + '**/blocks/autoloader-resilience.spec.js' ]; const config = defineConfig({ diff --git a/tests/test-main-autoload.php b/tests/test-main-autoload.php new file mode 100644 index 000000000..a7d5ca87f --- /dev/null +++ b/tests/test-main-autoload.php @@ -0,0 +1,102 @@ +autoload_classes(); + + $this->assertTrue( Otter_Autoload_Probe::$instantiated, 'Classes listed after an unavailable one should still be instantiated.' ); + } + + /** + * Non-string entries injected by a third-party filter must not fatal either. + */ + public function test_autoload_classes_skips_non_string_entries() { + add_filter( + 'otter_blocks_autoloader', + function () { + return array( null, array( 'nope' ), 'Otter_Autoload_Probe' ); + } + ); + + ( new Main() )->autoload_classes(); + + $this->assertTrue( Otter_Autoload_Probe::$instantiated ); + } + + /** + * Every class the plugin ships in the autoload list must be loadable, so a stale classmap is caught here instead of on a live site. + */ + public function test_bundled_classnames_are_loadable() { + $listed = array(); + + add_filter( + 'otter_blocks_autoloader', + function ( $classnames ) use ( &$listed ) { + $listed = $classnames; + + return array(); // Nothing to instantiate; the list itself is what is under test. + }, + 0 + ); + + ( new Main() )->autoload_classes(); + + $this->assertNotEmpty( $listed ); + + foreach ( $listed as $classname ) { + $this->assertTrue( class_exists( $classname ), $classname . ' is listed for autoloading but cannot be loaded.' ); + } + } +} From b8a7fd938c42a3746c04befd8364261b2d3b6e96 Mon Sep 17 00:00:00 2001 From: Luca Dobrescu Date: Mon, 3 Aug 2026 11:36:40 +0300 Subject: [PATCH 2/2] fix: resolve the plugin's own classes when the Composer classmap is stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composer exposes `inc/` through a generated classmap in `vendor/`, so a map that does not match the files on disk — an interrupted plugin update, an OPcache entry compiled from the previous version — makes a class that is present unloadable. Register a fallback loader for `ThemeIsle\GutenbergBlocks\*` that resolves a class from its file name. It is appended to the SPL stack, so Composer still answers first and the fallback only runs when Composer has no answer. The class then loads and its feature keeps working, instead of being skipped by the guard in Main::autoload_classes(). Co-Authored-By: Claude Opus 5 (1M context) --- inc/class-autoloader.php | 102 +++++++++++++++++++++++++++++++++++ otter-blocks.php | 5 ++ tests/test-main-autoload.php | 63 ++++++++++++++++++++-- 3 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 inc/class-autoloader.php diff --git a/inc/class-autoloader.php b/inc/class-autoloader.php new file mode 100644 index 000000000..7522a47d8 --- /dev/null +++ b/inc/class-autoloader.php @@ -0,0 +1,102 @@ + + */ + private static function candidates( $relative ) { + $files = array( OTTER_BLOCKS_PATH . '/inc/' . $relative . '.php' ); + + // The Integration namespace lives in inc/integrations/. + if ( 0 === strpos( $relative, 'integration/' ) ) { + $files[] = OTTER_BLOCKS_PATH . '/inc/integrations/' . substr( $relative, strlen( 'integration/' ) ) . '.php'; + } + + return $files; + } +} diff --git a/otter-blocks.php b/otter-blocks.php index 02a1722c6..e89ef308a 100644 --- a/otter-blocks.php +++ b/otter-blocks.php @@ -38,6 +38,11 @@ require_once $vendor_file; } +// Resolves the plugin's own classes from their file names when Composer's generated +// classmap does not match the files on disk. Registered last, so Composer stays first. +require_once OTTER_BLOCKS_PATH . '/inc/class-autoloader.php'; +\ThemeIsle\GutenbergBlocks\Autoloader::register(); + if ( class_exists( '\ThemeIsle\GutenbergBlocks\Main' ) ) { \ThemeIsle\GutenbergBlocks\Main::instance(); } diff --git a/tests/test-main-autoload.php b/tests/test-main-autoload.php index a7d5ca87f..0248cf9ff 100644 --- a/tests/test-main-autoload.php +++ b/tests/test-main-autoload.php @@ -5,6 +5,7 @@ * @package gutenberg-blocks */ +use ThemeIsle\GutenbergBlocks\Autoloader; use ThemeIsle\GutenbergBlocks\Main; /** @@ -79,6 +80,62 @@ function () { * Every class the plugin ships in the autoload list must be loadable, so a stale classmap is caught here instead of on a live site. */ public function test_bundled_classnames_are_loadable() { + $listed = $this->get_listed_classnames(); + + $this->assertNotEmpty( $listed ); + + foreach ( $listed as $classname ) { + $this->assertTrue( class_exists( $classname ), $classname . ' is listed for autoloading but cannot be loaded.' ); + } + } + + /** + * The fallback loader must cover the whole autoload list, so a class stays reachable when Composer's generated classmap does not match the files on disk. + */ + public function test_fallback_autoloader_resolves_every_listed_classname() { + foreach ( $this->get_listed_classnames() as $classname ) { + $this->assertNotFalse( + Autoloader::path_for( ltrim( $classname, '\\' ) ), + $classname . ' cannot be resolved from its file name; the fallback autoloader no longer covers the autoload list.' + ); + } + } + + /** + * File name mapping, including the Integration namespace that lives in inc/integrations/. + */ + public function test_path_for_maps_class_names_to_files() { + $this->assertSame( + OTTER_BLOCKS_PATH . '/inc/plugins/class-atomic-wind-blocks.php', + Autoloader::path_for( 'ThemeIsle\GutenbergBlocks\Plugins\Atomic_Wind_Blocks' ) + ); + + $this->assertSame( + OTTER_BLOCKS_PATH . '/inc/integrations/class-form-providers.php', + Autoloader::path_for( 'ThemeIsle\GutenbergBlocks\Integration\Form_Providers' ) + ); + + $this->assertSame( + OTTER_BLOCKS_PATH . '/inc/class-main.php', + Autoloader::path_for( 'ThemeIsle\GutenbergBlocks\Main' ) + ); + } + + /** + * Classes outside the plugin namespace, and names with no file, are left to the other loaders. + */ + public function test_path_for_ignores_foreign_and_missing_classes() { + $this->assertFalse( Autoloader::path_for( 'WP_Query' ) ); + $this->assertFalse( Autoloader::path_for( 'ThemeIsle\OtterPro\Plugins\License' ) ); + $this->assertFalse( Autoloader::path_for( 'ThemeIsle\GutenbergBlocks\Plugins\Definitely_Missing_Class' ) ); + } + + /** + * The class list the plugin passes through the `otter_blocks_autoloader` filter. + * + * @return array + */ + private function get_listed_classnames() { $listed = array(); add_filter( @@ -93,10 +150,6 @@ function ( $classnames ) use ( &$listed ) { ( new Main() )->autoload_classes(); - $this->assertNotEmpty( $listed ); - - foreach ( $listed as $classname ) { - $this->assertTrue( class_exists( $classname ), $classname . ' is listed for autoloading but cannot be loaded.' ); - } + return $listed; } }