diff --git a/inc/class-base-css.php b/inc/class-base-css.php index 8d804cc3b..146269f9b 100644 --- a/inc/class-base-css.php +++ b/inc/class-base-css.php @@ -384,6 +384,15 @@ public function get_animation_css( $blocks ) { return $style; } + if ( ! self::has_own_css_parser() ) { + // Another plugin loaded a different php-css-parser release; mixing its + // classes with the bundled ones fatals at class-link time (issue #2942). + // Skip the optimization — Blocks_Animation::frontend_load() serves the + // full stock stylesheet whenever this guard fails, so the animation + // rules never depend on the optimized fragment generated here. + return $style; + } + $prepared_classes = array( ':root' ); foreach ( $classes as $class ) { @@ -445,6 +454,74 @@ public function get_animation_css( $blocks ) { return $style; } + /** + * Check that every Sabberworm class the animation parser touches resolves to + * the copy bundled with this plugin. + * + * Another active plugin can ship a different php-css-parser release under the + * same global namespace. Once any of its classes or interfaces is loaded, + * loading the bundled counterparts fatals at class-link time with a + * declaration-compatibility error, and that error is not catchable. + * + * @return bool + */ + public static function has_own_css_parser() { + $own_vendor = wp_normalize_path( OTTER_BLOCKS_PATH . '/vendor/' ); + $prefix = 'Sabberworm\\CSS\\'; + + // Reject anything already in memory from a foreign copy first, before the + // sentinel checks below can autoload any bundled class: the parser touches + // more classes than the sentinels (OutputFormat, KeyFrame, the cached + // object graph...), and any preloaded foreign one poisons the process. + $declared = array_merge( get_declared_classes(), get_declared_interfaces(), get_declared_traits() ); + + foreach ( $declared as $declared_name ) { + // Case-insensitive: PHP class and namespace names are case-insensitive, + // so a foreign copy declared with different casing is the same class. + if ( 0 !== stripos( $declared_name, $prefix ) ) { + continue; + } + + if ( ! self::is_bundled_class( $declared_name, $own_vendor ) ) { + return false; + } + } + + // Entry points nothing may have loaded yet: whichever autoloader resolves + // them must serve the bundled copy. + $sentinels = array( + '\Sabberworm\CSS\Parser', + '\Sabberworm\CSS\Comment\Commentable', + '\Sabberworm\CSS\Renderable', + ); + + foreach ( $sentinels as $sentinel ) { + if ( ! class_exists( $sentinel ) && ! interface_exists( $sentinel ) ) { + return false; + } + + if ( ! self::is_bundled_class( $sentinel, $own_vendor ) ) { + return false; + } + } + + return true; + } + + /** + * Check that a class, interface, or trait was loaded from this plugin's vendor directory. + * + * @param string $name Fully qualified name. + * @param string $own_vendor Normalized path of this plugin's vendor directory. + * @return bool + */ + private static function is_bundled_class( $name, $own_vendor ) { + $reflection = new \ReflectionClass( $name ); + $file = $reflection->getFileName(); + + return false !== $file && 0 === strpos( wp_normalize_path( $file ), $own_vendor ); + } + /** * Get Animation Classes * diff --git a/inc/class-blocks-animation.php b/inc/class-blocks-animation.php index 78181b08c..badbffcdb 100644 --- a/inc/class-blocks-animation.php +++ b/inc/class-blocks-animation.php @@ -172,7 +172,15 @@ public function frontend_load( $block_content, $block ) { } if ( ! self::$scripts_loaded['animation'] && strpos( $block_content, 'animated' ) ) { - if ( ! defined( 'OTTER_BLOCKS_VERSION' ) || ( defined( 'OTTER_BLOCKS_VERSION' ) && ! get_option( 'themeisle_blocks_settings_optimize_animations_css', true ) ) ) { + // The stock stylesheet also serves pages whose generated CSS was built + // while a foreign php-css-parser copy blocked the optimization (issue + // #2942) — the cached CSS carries no animation rules, so this delivery + // must not depend on the optimized path having run. + if ( + ! defined( 'OTTER_BLOCKS_VERSION' ) || + ! get_option( 'themeisle_blocks_settings_optimize_animations_css', true ) || + ! Base_CSS::has_own_css_parser() + ) { wp_enqueue_style( 'otter-animation' ); } diff --git a/packages/e2e-tests/mu-plugins/includes/foreign-sabberworm-interface.php b/packages/e2e-tests/mu-plugins/includes/foreign-sabberworm-interface.php new file mode 100644 index 000000000..308bace7c --- /dev/null +++ b/packages/e2e-tests/mu-plugins/includes/foreign-sabberworm-interface.php @@ -0,0 +1,22 @@ + \WP_REST_Server::CREATABLE, + 'permission_callback' => __NAMESPACE__ . '\\require_admin', + 'callback' => function ( \WP_REST_Request $request ) { + $mode = $request->get_param( 'mode' ); + + if ( ! in_array( $mode, array( 'foreign', 'own' ), true ) ) { + return new \WP_Error( + 'otter_e2e_invalid_sabberworm_mode', + 'Mode must be "foreign" or "own".', + array( 'status' => 400 ) + ); + } + + if ( 'foreign' === $mode ) { + update_option( FOREIGN_SABBERWORM_OPTION, true, false ); + } else { + delete_option( FOREIGN_SABBERWORM_OPTION ); + } + + // Force the next frontend request through the parse path. + delete_transient( 'otter_animations_parsed' ); + + return rest_ensure_response( array( 'ok' => true ) ); + }, + ) + ); + register_rest_route( REST_NAMESPACE, '/widgets/seed', @@ -1497,6 +1548,7 @@ function () { delete_option( CAPTCHA_MODE_OPTION ); delete_option( OPENAI_STUB_OPTION ); delete_option( FS_BLOCKED_OPTION ); + delete_option( FOREIGN_SABBERWORM_OPTION ); cleanup_form_records(); return rest_ensure_response( array( 'ok' => true ) ); }, diff --git a/src/blocks/test/e2e/blocks/sabberworm-collision.spec.js b/src/blocks/test/e2e/blocks/sabberworm-collision.spec.js new file mode 100644 index 000000000..99f470a4f --- /dev/null +++ b/src/blocks/test/e2e/blocks/sabberworm-collision.spec.js @@ -0,0 +1,131 @@ +/** + * Internal dependencies + */ +import { test, expect } from '../fixtures'; + +/** + * Frontend animation-CSS coverage for https://github.com/Codeinwp/otter-blocks/issues/2942. + * + * When another plugin loads a different php-css-parser release, mixing its + * classes with Otter's bundled copy fatals at class-link time while + * Base_CSS::get_animation_css() parses the animation stylesheet. The scenario + * mu-plugin predefines the typed 9.x `Commentable` interface before plugins + * load; the page must still render, with the full stock animation stylesheet + * enqueued instead of the optimized inline subset — including on later + * requests served from the generated post-CSS cache, which carries no + * animation rules while the guard fails. + * + * Each test creates its own fresh post AFTER switching modes: the first + * singular view generates and caches the post CSS, and cached requests never + * reach the parser again. + * + * Serial project: flips a site-wide scenario flag that affects every request. + */ + +// The Progress Bar makes the generated post CSS non-empty, so the second +// request is served from the cached stylesheet file. +const FOREIGN_POST_CONTENT = ` +
Animated collision probe
+ + + + +`; + +const OWN_POST_CONTENT = ` +Animated collision probe
+`; + +test.describe( 'Sabberworm collision fallback', () => { + const createdPosts = []; + + const createProbePost = async( requestUtils, title, content ) => { + const post = await requestUtils.rest({ + method: 'POST', + path: '/wp/v2/posts', + data: { + status: 'publish', + title, + content + } + }); + + createdPosts.push( post.id ); + + // Plain query form: independent of the permalink structure. + return `/?p=${ post.id }`; + }; + + test.afterAll( async({ requestUtils }) => { + await requestUtils.rest({ + method: 'POST', + path: '/otter-e2e/v1/sabberworm', + data: { mode: 'own' } + }); + + // Only this spec's own posts — other specs run against the same site. + // Best-effort per post: one failed request must not orphan the rest. + while ( createdPosts.length ) { + const postId = createdPosts.pop(); + try { + await requestUtils.rest({ + method: 'DELETE', + path: `/wp/v2/posts/${ postId }`, + params: { force: true } + }); + } catch ( error ) { + console.warn( `Could not delete post ${ postId }:`, error.message ); + } + } + }); + + test( 'serves the full stylesheet when a foreign parser is loaded, also from the CSS cache', async({ page, otterUtils, requestUtils }) => { + await otterUtils.setSabberwormMode( 'foreign' ); + + try { + const postUrl = await createProbePost( requestUtils, 'Foreign parser probe', FOREIGN_POST_CONTENT ); + + const response = await page.goto( postUrl ); + + expect( response.status() ).toBe( 200 ); + + await expect( page.getByText( 'Animated collision probe' ) ).toBeVisible(); + + // Assert on the server response: the animation frontend script rewrites + // the block's classes in the live DOM once the animation plays. + const html = await response.text(); + expect( html ).toContain( 'animated fadeIn' ); + expect( html ).not.toContain( 'Fatal error' ); + expect( html ).not.toContain( 'must be compatible' ); + + // The optimization is skipped, so the stock stylesheet carries the animations. + expect( html ).toContain( 'otter-animation-css' ); + expect( html ).toMatch( /animation\/index\.css/ ); + + // The first request generated and cached the post CSS without animation + // rules; the fallback must survive requests served from that cache. + const cachedResponse = await page.goto( postUrl ); + const cachedHtml = await cachedResponse.text(); + expect( cachedHtml ).toContain( 'otter-animation-css' ); + expect( cachedHtml ).not.toContain( 'Fatal error' ); + } finally { + await otterUtils.setSabberwormMode( 'own' ); + } + }); + + test( 'inlines the optimized animation CSS with the bundled parser', async({ page, otterUtils, requestUtils }) => { + await otterUtils.setSabberwormMode( 'own' ); + + const postUrl = await createProbePost( requestUtils, 'Bundled parser probe', OWN_POST_CONTENT ); + + const response = await page.goto( postUrl ); + + await expect( page.getByText( 'Animated collision probe' ) ).toBeVisible(); + + // The optimized subset is served: the fadeIn keyframe is present without + // the full stock stylesheet. + const html = await response.text(); + expect( html ).toContain( '@keyframes fadeIn' ); + expect( html ).not.toContain( 'otter-animation-css' ); + }); +}); diff --git a/src/blocks/test/e2e/fixtures.ts b/src/blocks/test/e2e/fixtures.ts index dd13ff773..374f22a75 100644 --- a/src/blocks/test/e2e/fixtures.ts +++ b/src/blocks/test/e2e/fixtures.ts @@ -70,6 +70,13 @@ export type OtterUtils = { /** Remove the seeded widget, its CSS file/options, and the filesystem block. */ cleanupOtterWidget: () => Promise