-
Notifications
You must be signed in to change notification settings - Fork 36
fix: Sabberworm dependency collision causes fatal during animation CSS parsing #2958
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
cc76950
3e83b34
b2d3f81
dd2adc6
2b07546
29b77c6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -384,6 +384,14 @@ public function get_animation_css( $blocks ) { | |
| return $style; | ||
| } | ||
|
|
||
| if ( ! self::has_own_css_parser() ) { | ||
| // A foreign php-css-parser release is loaded; parsing now fatals | ||
| // uncatchably at class-link time (#2942). The frontend loader serves | ||
| // the stock stylesheet instead. | ||
| error_log( '[Otter Blocks] A conflicting Sabberworm php-css-parser release is loaded; skipping animation CSS optimization and serving the stock stylesheet instead.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log | ||
| return $style; | ||
| } | ||
|
|
||
| $prepared_classes = array( ':root' ); | ||
|
|
||
| foreach ( $classes as $class ) { | ||
|
|
@@ -445,6 +453,70 @@ public function get_animation_css( $blocks ) { | |
| return $style; | ||
| } | ||
|
|
||
| /** | ||
| * Check that every loaded Sabberworm\CSS symbol resolves to this plugin's copy. | ||
| * | ||
| * Another plugin can ship a different php-css-parser release under the same | ||
| * namespace. Once any of its classes loads, loading the bundled counterparts | ||
| * fatals at class-link time, 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 any foreign copy already in memory before the sentinel checks | ||
| // autoload a bundled class: the parser uses more classes than the | ||
| // sentinels, and one preloaded foreign symbol poisons the process. | ||
| $declared = array_merge( get_declared_classes(), get_declared_interfaces(), get_declared_traits() ); | ||
|
|
||
| foreach ( $declared as $declared_name ) { | ||
| // PHP class names are case-insensitive; match a foreign copy in any casing. | ||
| 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', | ||
| ); | ||
|
Comment on lines
+487
to
+491
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 237f9a9 — |
||
|
|
||
| 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 | ||
| * | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| <?php | ||
| /** | ||
| * Typed `Commentable` interface as shipped by php-css-parser 9.x. | ||
| * | ||
| * Lives in a subdirectory so WordPress does not auto-load it as an mu-plugin; | ||
| * otter-e2e-bootstrap.php requires it only while the foreign-Sabberworm | ||
| * scenario (issue #2942) is armed. Once defined, loading Otter's bundled | ||
| * untyped CSSList fatals at class-link time — exactly like a second plugin | ||
| * shipping a newer parser release. | ||
| * | ||
| * @package otter-blocks | ||
| */ | ||
|
|
||
| // phpcs:ignoreFile -- deliberately mirrors the upstream 9.x signatures. | ||
|
|
||
| namespace Sabberworm\CSS\Comment; | ||
|
|
||
| interface Commentable { | ||
| public function addComments( array $comments ): void; | ||
| public function getComments(): array; | ||
| public function setComments( array $comments ): void; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = `<!-- wp:paragraph {"className":"animated fadeIn"} --> | ||
| <p class="animated fadeIn">Animated collision probe</p> | ||
| <!-- /wp:paragraph --> | ||
|
|
||
| <!-- wp:themeisle-blocks/progress-bar {"id":"wp-block-themeisle-blocks-progress-bar-e2e2942","title":"Collision probe","percentage":75,"titleColor":"#123abc","height":36} --> | ||
| <div id="wp-block-themeisle-blocks-progress-bar-e2e2942" class="wp-block-themeisle-blocks-progress-bar"><div class="wp-block-themeisle-blocks-progress-bar__title">Collision probe</div><div class="wp-block-themeisle-blocks-progress-bar__area"><div class="wp-block-themeisle-blocks-progress-bar__area__bar"></div></div></div> | ||
| <!-- /wp:themeisle-blocks/progress-bar -->`; | ||
|
|
||
| const OWN_POST_CONTENT = `<!-- wp:paragraph {"className":"animated fadeIn"} --> | ||
| <p class="animated fadeIn">Animated collision probe</p> | ||
| <!-- /wp:paragraph -->`; | ||
|
|
||
| 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' ); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's also add here a log similar to #2956 (comment) to mark that something is preventing it from working correctly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in 55690d3 — the collision fallback now logs
[Otter Blocks] A conflicting Sabberworm php-css-parser release is loaded; skipping animation CSS optimization and serving the stock stylesheet instead.before returning, mirroring the autoload-skip notice from #2956, so a foreign parser release no longer looks like the feature silently stopped. Verified in the standalone sandbox (bothcommentableandoutputformatscenarios) and via theTest_Animation_CSSPHPUnit suite (3 tests / 10 assertions green) — the positive parse path is unaffected.