diff --git a/plugins/newspack-plugin/includes/class-newspack.php b/plugins/newspack-plugin/includes/class-newspack.php index 606fcf9762..2e7e76968c 100644 --- a/plugins/newspack-plugin/includes/class-newspack.php +++ b/plugins/newspack-plugin/includes/class-newspack.php @@ -222,6 +222,7 @@ private function includes() { include_once NEWSPACK_ABSPATH . 'includes/plugins/class-mailchimp-for-woocommerce.php'; include_once NEWSPACK_ABSPATH . 'includes/plugins/class-onesignal.php'; include_once NEWSPACK_ABSPATH . 'includes/plugins/class-organic-profile-block.php'; + include_once NEWSPACK_ABSPATH . 'includes/plugins/class-woocommerce-content-detector.php'; include_once NEWSPACK_ABSPATH . 'includes/plugins/class-perfmatters.php'; include_once NEWSPACK_ABSPATH . 'includes/plugins/class-pwa.php'; include_once NEWSPACK_ABSPATH . 'includes/plugins/co-authors-plus/class-author-rest-fields.php'; diff --git a/plugins/newspack-plugin/includes/plugins/class-perfmatters.php b/plugins/newspack-plugin/includes/plugins/class-perfmatters.php index 9792d7c90b..59da3094c2 100644 --- a/plugins/newspack-plugin/includes/plugins/class-perfmatters.php +++ b/plugins/newspack-plugin/includes/plugins/class-perfmatters.php @@ -22,6 +22,7 @@ public static function init() { add_filter( 'perfmatters_lazyload_youtube_thumbnail_resolution', [ __CLASS__, 'maybe_serve_high_res_youtube_thumbs' ] ); add_filter( 'perfmatters_rucss_excluded_stylesheets', [ __CLASS__, 'add_rucss_excluded_stylesheets' ] ); add_filter( 'perfmatters_delay_js', [ __CLASS__, 'should_delay_js' ] ); + add_filter( 'perfmatters_disable_woocommerce_scripts', [ __CLASS__, 'maybe_keep_woocommerce_assets' ] ); } /** @@ -382,5 +383,25 @@ public static function should_delay_js( $delay_js ) { } return $delay_js; } + + /** + * Veto Perfmatters' "Disable WooCommerce Scripts" strip on requests that + * actually render WooCommerce content, so block/shortcode styles aren't lost + * (NPPM-193). Keeps the global default `disable_woocommerce_scripts => true` + * intact, so the perf win stands on every other request. + * + * @param bool $disable Whether Perfmatters should disable WC scripts/styles. + * + * @return bool + */ + public static function maybe_keep_woocommerce_assets( $disable ) { + if ( self::should_ignore_defaults() ) { + return $disable; + } + if ( WooCommerce_Content_Detector::current_request_has_woocommerce_content() ) { + return false; + } + return $disable; + } } Perfmatters::init(); diff --git a/plugins/newspack-plugin/includes/plugins/class-woocommerce-content-detector.php b/plugins/newspack-plugin/includes/plugins/class-woocommerce-content-detector.php new file mode 100644 index 0000000000..cab82e066a --- /dev/null +++ b/plugins/newspack-plugin/includes/plugins/class-woocommerce-content-detector.php @@ -0,0 +1,349 @@ + $e->getMessage() ], + 'error' + ); + } catch ( \Throwable $log_error ) { + // Last resort if a newspack_log listener throws: the local logger + // writes without dispatching an action, so fail-open still holds. + Logger::log( 'WooCommerce content detection fail-open log failed: ' . $log_error->getMessage(), 'NEWSPACK-PERFMATTERS', 'error' ); + } + } + + return self::$memo; + } + + /** + * Reset the per-request memo. For tests only. + */ + public static function reset_memo() { + self::$memo = null; + } + + /** + * Recursive markup scanner: matchers first, then indirection expansion. + * + * @param string $markup Block markup to scan. + * @param array $visited Reference set of already-resolved refs ("type:id"). + * @return bool + */ + private static function markup_has_woocommerce( $markup, &$visited ) { + if ( ! is_string( $markup ) || '' === $markup ) { + return false; + } + if ( self::markup_has_wc_block( $markup ) || self::markup_has_wc_shortcode( $markup ) ) { + return true; + } + return self::expand_references( $markup, $visited ); + } + + /** + * Whether markup contains any woocommerce/* block (catches any nesting depth + * because serialized block markup is inline). + * + * @param string $markup Block markup. + * @return bool + */ + private static function markup_has_wc_block( $markup ) { + return str_contains( $markup, '', + ] + ); + $this->go_to( get_permalink( $page ) ); + WooCommerce_Content_Detector::reset_memo(); + $this->assertFalse( Perfmatters::maybe_keep_woocommerce_assets( true ) ); + } + + /** + * When no WooCommerce content is present, the callback passes the incoming + * value through unchanged. + */ + public function test_passes_through_when_no_wc_content() { + $page = self::factory()->post->create( + [ + 'post_type' => 'page', + 'post_content' => '
hi
', + ] + ); + $this->go_to( get_permalink( $page ) ); + WooCommerce_Content_Detector::reset_memo(); + $this->assertTrue( Perfmatters::maybe_keep_woocommerce_assets( true ) ); + $this->assertFalse( Perfmatters::maybe_keep_woocommerce_assets( false ) ); + } + + /** + * With NEWSPACK_IGNORE_PERFMATTERS_DEFAULTS defined, the callback returns the + * incoming value untouched and never consults the detector. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_ignore_defaults_passes_through() { + define( 'NEWSPACK_IGNORE_PERFMATTERS_DEFAULTS', true ); + $this->assertTrue( Perfmatters::maybe_keep_woocommerce_assets( true ) ); + $this->assertFalse( Perfmatters::maybe_keep_woocommerce_assets( false ) ); + } +} diff --git a/plugins/newspack-plugin/tests/unit-tests/woocommerce-content-detector.php b/plugins/newspack-plugin/tests/unit-tests/woocommerce-content-detector.php new file mode 100644 index 0000000000..6b759822dc --- /dev/null +++ b/plugins/newspack-plugin/tests/unit-tests/woocommerce-content-detector.php @@ -0,0 +1,460 @@ +prior_products_shortcode = $GLOBALS['shortcode_tags']['products'] ?? null; + } + + /** + * Reset the memo and restore the `products` shortcode to its pre-test state. + */ + public function tearDown(): void { + WooCommerce_Content_Detector::reset_memo(); + remove_shortcode( 'products' ); + if ( null !== $this->prior_products_shortcode ) { + add_shortcode( 'products', $this->prior_products_shortcode ); + } + $this->prior_products_shortcode = null; + parent::tearDown(); + } + + /** + * A queried page containing a woocommerce/* block is detected, + * including when the block is nested inside another block. + */ + public function test_detects_wc_block_in_queried_post() { + $page = self::factory()->post->create( + [ + 'post_type' => 'page', + 'post_content' => '', + ] + ); + $this->go_to( get_permalink( $page ) ); + WooCommerce_Content_Detector::reset_memo(); + $this->assertTrue( WooCommerce_Content_Detector::current_request_has_woocommerce_content() ); + } + + /** + * A queried page containing a registered WooCommerce shortcode is detected. + */ + public function test_detects_wc_shortcode_in_queried_post() { + add_shortcode( 'products', '__return_empty_string' ); + $page = self::factory()->post->create( + [ + 'post_type' => 'page', + 'post_content' => '[products limit="4"]
', + ] + ); + $this->go_to( get_permalink( $page ) ); + WooCommerce_Content_Detector::reset_memo(); + $this->assertTrue( WooCommerce_Content_Detector::current_request_has_woocommerce_content() ); + } + + /** + * A queried page with no WooCommerce content is not detected. + */ + public function test_clean_queried_post_is_not_detected() { + $page = self::factory()->post->create( + [ + 'post_type' => 'page', + 'post_content' => 'Just words.
', + ] + ); + $this->go_to( get_permalink( $page ) ); + WooCommerce_Content_Detector::reset_memo(); + $this->assertFalse( WooCommerce_Content_Detector::current_request_has_woocommerce_content() ); + } + + /** + * A singular custom post type is scanned the same way (post-type-agnostic). + */ + public function test_detects_wc_block_in_singular_cpt() { + register_post_type( 'np_test_cpt', [ 'public' => true ] ); + $post = self::factory()->post->create( + [ + 'post_type' => 'np_test_cpt', + 'post_content' => '', + ] + ); + $this->go_to( get_permalink( $post ) ); + WooCommerce_Content_Detector::reset_memo(); + $this->assertTrue( WooCommerce_Content_Detector::current_request_has_woocommerce_content() ); + unregister_post_type( 'np_test_cpt' ); + } + + /** + * A WooCommerce block in a widget assigned to an ACTIVE sidebar is detected. + */ + public function test_detects_wc_block_in_active_block_widget() { + $clean = self::factory()->post->create( + [ + 'post_type' => 'page', + 'post_content' => 'clean
', + ] + ); + $this->go_to( get_permalink( $clean ) ); + update_option( + 'widget_block', + [ + 2 => [ 'content' => 'nope
' ], + 3 => [ 'content' => '' ], + ] + ); + wp_set_sidebars_widgets( + [ + 'sidebar-1' => [ 'block-3' ], + 'wp_inactive_widgets' => [], + ] + ); + WooCommerce_Content_Detector::reset_memo(); + $this->assertTrue( WooCommerce_Content_Detector::current_request_has_woocommerce_content() ); + } + + /** + * The SAME WooCommerce widget, present only in wp_inactive_widgets, is NOT + * detected (locks in the active-only scope — orphaned widgets must not veto + * the strip site-wide). + */ + public function test_inactive_block_widget_is_not_detected() { + $clean = self::factory()->post->create( + [ + 'post_type' => 'page', + 'post_content' => 'clean
', + ] + ); + $this->go_to( get_permalink( $clean ) ); + update_option( + 'widget_block', + [ 3 => [ 'content' => '' ] ] + ); + wp_set_sidebars_widgets( + [ + 'sidebar-1' => [], + 'wp_inactive_widgets' => [ 'block-3' ], + ] + ); + WooCommerce_Content_Detector::reset_memo(); + $this->assertFalse( WooCommerce_Content_Detector::current_request_has_woocommerce_content() ); + } + + /** + * A WooCommerce block inside a synced pattern (core/block) referenced by the + * queried page is detected — the same failure mode as the reported incident. + */ + public function test_detects_wc_block_in_synced_pattern() { + $pattern = self::factory()->post->create( + [ + 'post_type' => 'wp_block', + 'post_content' => '', + ] + ); + $page = self::factory()->post->create( + [ + 'post_type' => 'page', + 'post_content' => '', + ] + ); + $this->go_to( get_permalink( $page ) ); + WooCommerce_Content_Detector::reset_memo(); + $this->assertTrue( WooCommerce_Content_Detector::current_request_has_woocommerce_content() ); + } + + /** + * Synced patterns nested two levels deep are detected (recursion). + */ + public function test_detects_wc_block_in_nested_synced_pattern() { + $inner = self::factory()->post->create( + [ + 'post_type' => 'wp_block', + 'post_content' => '', + ] + ); + $outer = self::factory()->post->create( + [ + 'post_type' => 'wp_block', + 'post_content' => '', + ] + ); + $page = self::factory()->post->create( + [ + 'post_type' => 'page', + 'post_content' => '', + ] + ); + $this->go_to( get_permalink( $page ) ); + WooCommerce_Content_Detector::reset_memo(); + $this->assertTrue( WooCommerce_Content_Detector::current_request_has_woocommerce_content() ); + } + + /** + * A WooCommerce block in the resolved FSE template content is detected. + */ + public function test_detects_wc_block_in_fse_template() { + // twentytwentyfour is a block theme bundled with the WP test scaffold; + // scan_fse_template gates on wp_is_block_theme(), so any block theme works. + switch_theme( 'twentytwentyfour' ); + if ( ! function_exists( 'wp_is_block_theme' ) || ! wp_is_block_theme() ) { + switch_theme( WP_DEFAULT_THEME ); + $this->markTestSkipped( 'No block theme available in this environment.' ); + } + $clean = self::factory()->post->create( + [ + 'post_type' => 'page', + 'post_content' => 'clean
', + ] + ); + $this->go_to( get_permalink( $clean ) ); + $GLOBALS['_wp_current_template_content'] = ''; + WooCommerce_Content_Detector::reset_memo(); + $result = WooCommerce_Content_Detector::current_request_has_woocommerce_content(); + unset( $GLOBALS['_wp_current_template_content'] ); + switch_theme( WP_DEFAULT_THEME ); + $this->assertTrue( $result ); + } + + /** + * An empty template-content global is a clean miss for the FSE source, not + * an error. + */ + public function test_empty_fse_template_global_is_not_detected() { + // twentytwentyfour is a block theme bundled with the WP test scaffold; + // scan_fse_template gates on wp_is_block_theme(), so any block theme works. + switch_theme( 'twentytwentyfour' ); + if ( ! function_exists( 'wp_is_block_theme' ) || ! wp_is_block_theme() ) { + switch_theme( WP_DEFAULT_THEME ); + $this->markTestSkipped( 'No block theme available in this environment.' ); + } + $clean = self::factory()->post->create( + [ + 'post_type' => 'page', + 'post_content' => 'clean
', + ] + ); + $this->go_to( get_permalink( $clean ) ); + unset( $GLOBALS['_wp_current_template_content'] ); + WooCommerce_Content_Detector::reset_memo(); + $result = WooCommerce_Content_Detector::current_request_has_woocommerce_content(); + switch_theme( WP_DEFAULT_THEME ); + $this->assertFalse( $result ); + } + + /** + * A cyclic synced-pattern reference with no WooCommerce content terminates + * (cycle guard) and returns false rather than looping forever. + */ + public function test_cyclic_synced_patterns_terminate() { + $a = self::factory()->post->create( + [ + 'post_type' => 'wp_block', + 'post_content' => 'A', + ] + ); + $b = self::factory()->post->create( + [ + 'post_type' => 'wp_block', + 'post_content' => '', + ] + ); + $this->assertNotEmpty( + wp_update_post( + [ + 'ID' => $a, + 'post_content' => '', + ] + ), + 'Setup: post A must be updated to reference B so a real cycle exists.' + ); + $page = self::factory()->post->create( + [ + 'post_type' => 'page', + 'post_content' => '', + ] + ); + $this->go_to( get_permalink( $page ) ); + WooCommerce_Content_Detector::reset_memo(); + $this->assertFalse( WooCommerce_Content_Detector::current_request_has_woocommerce_content() ); + } + + /** + * A non-WP_Post queried object (e.g. a term archive) is handled safely by + * scan_queried_post and, with no other WooCommerce content, is not detected. + */ + public function test_non_wp_post_queried_object_is_not_detected() { + $cat = self::factory()->category->create( [ 'name' => 'np-test-cat' ] ); + $post = self::factory()->post->create( + [ + 'post_type' => 'post', + 'post_content' => 'plain
', + ] + ); + wp_set_post_categories( $post, [ $cat ] ); + $this->go_to( get_category_link( $cat ) ); + WooCommerce_Content_Detector::reset_memo(); + // get_queried_object() is a WP_Term here, not a WP_Post. + $this->assertFalse( WooCommerce_Content_Detector::current_request_has_woocommerce_content() ); + } + + /** + * If a source throws, detection fails open (returns true) and logs via + * newspack_log so a persistent failure is observable. + */ + public function test_fails_open_and_logs_on_error() { + $clean = self::factory()->post->create( + [ + 'post_type' => 'page', + 'post_content' => 'clean
', + ] + ); + $this->go_to( get_permalink( $clean ) ); + // Ensure the widget source is reached, then make its option read throw. + wp_set_sidebars_widgets( + [ + 'sidebar-1' => [ 'block-2' ], + 'wp_inactive_widgets' => [], + ] + ); + // Intentionally throws (never returns) to exercise the detector's fail-open path. + $throwing_filter = function () { // phpcs:ignore WordPressVIPMinimum.Hooks.AlwaysReturnInFilter.MissingReturnStatement + throw new \RuntimeException( 'boom' ); + }; + add_filter( 'option_widget_block', $throwing_filter ); + $logged_code = null; + add_action( + 'newspack_log', + function ( $code ) use ( &$logged_code ) { + $logged_code = $code; + }, + 10, + 1 + ); + WooCommerce_Content_Detector::reset_memo(); + try { + $this->assertTrue( WooCommerce_Content_Detector::current_request_has_woocommerce_content() ); + $this->assertSame( 'newspack_perfmatters_wc_detection_error', $logged_code ); + } finally { + // Remove the throwing filter so it can't make later tests order-dependent, + // even if an assertion above fails. + remove_filter( 'option_widget_block', $throwing_filter ); + } + } + + /** + * A WooCommerce block inside a template part — referenced via core/template-part + * in the FSE template global — is detected by following the part reference into + * the separately-stored wp_template_part post. + * + * This exercises the resolve_template_part() → get_block_template() branch: the + * global contains only a template-part reference (no inline WC block), so a true + * result can only come from resolving the reference into the part's content. + */ + public function test_detects_wc_block_via_template_part_resolution() { + switch_theme( 'twentytwentyfour' ); + if ( ! function_exists( 'wp_is_block_theme' ) || ! wp_is_block_theme() ) { + switch_theme( WP_DEFAULT_THEME ); + $this->markTestSkipped( 'No block theme available in this environment.' ); + } + + // Create a wp_template_part post whose content contains a WooCommerce block. + // get_block_template() queries by post_name + wp_theme taxonomy term name. + $theme = get_stylesheet(); + $slug = 'np-test-wc-part'; + $part_id = self::factory()->post->create( + [ + 'post_type' => 'wp_template_part', + 'post_status' => 'publish', + 'post_name' => $slug, + 'post_content' => '', + 'post_title' => 'NP Test WC Part', + ] + ); + // Assign the active theme's wp_theme taxonomy term so get_block_template() + // can find this part via the "clean
', + ] + ); + $this->go_to( get_permalink( $clean ) ); + WooCommerce_Content_Detector::reset_memo(); + $result = WooCommerce_Content_Detector::current_request_has_woocommerce_content(); + + // Cleanup before assertion so the theme switch runs even on failure. + unset( $GLOBALS['_wp_current_template_content'] ); + switch_theme( WP_DEFAULT_THEME ); + + $this->assertTrue( $result ); + } + + /** + * The result is memoized: a second call does not re-run the sources. Asserted + * via a spy on the widget_block option read (which the widget source performs + * once on a clean page) — the count must not increase on the second call. + */ + public function test_result_is_memoized() { + $clean = self::factory()->post->create( + [ + 'post_type' => 'page', + 'post_content' => 'clean
', + ] + ); + $this->go_to( get_permalink( $clean ) ); + wp_set_sidebars_widgets( + [ + 'sidebar-1' => [ 'block-2' ], + 'wp_inactive_widgets' => [], + ] + ); + $reads = 0; + add_filter( + 'option_widget_block', + function ( $value ) use ( &$reads ) { + $reads++; + return $value; + } + ); + WooCommerce_Content_Detector::reset_memo(); + WooCommerce_Content_Detector::current_request_has_woocommerce_content(); + $after_first = $reads; + WooCommerce_Content_Detector::current_request_has_woocommerce_content(); + $this->assertSame( $after_first, $reads, 'Second call must not re-read options (memoized).' ); + $this->assertGreaterThan( 0, $after_first, 'Sanity: the widget source ran on the first call.' ); + } +}