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
71 changes: 62 additions & 9 deletions plugins/newspack-plugin/includes/plugins/class-perfmatters.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,46 @@ private static function scripts_to_delay() {
return $scripts_to_delay;
}

/**
* Scripts on the reveal path of above-header prompts.
*
* These are excluded from JS delay when published above-header prompts exist so the
* prompts show immediately: the Campaigns view script (newspack-popups) removes the
* prompt's `hidden` class, and the reader data library (window.newspack /
* newspack-plugin) drives that reveal when segments are configured.
*
* @return string[] Script identifiers.
*/
private static function above_header_reveal_scripts() {
return [ 'newspack-popups', 'window.newspack', 'newspack-plugin' ];
}

/**
* Reveal-path scripts to exclude from JS deferral when above-header prompts exist.
*
* Derived from above_header_reveal_scripts() minus `window.newspack`: that token
* matches an inline script, and Perfmatters' deferral only applies to external
* `<script src>` files, so it is meaningless as a defer exclusion. Kept derived from
* the reveal-script set so the delay and defer lists cannot silently drift.
*
* @return string[] Script identifiers.
*/
private static function above_header_defer_exclusions() {
return array_values( array_diff( self::above_header_reveal_scripts(), [ 'window.newspack' ] ) );
}

/**
* Whether above-header prompts should be revealed immediately (excluded from JS
* delay/deferral). True when the Campaigns plugin reports at least one published
* above-header prompt.
*
* @return bool
*/
private static function has_immediate_above_header_prompts() {
return method_exists( 'Newspack_Popups_Model', 'has_published_above_header_prompts' )
&& \Newspack_Popups_Model::has_published_above_header_prompts();
}

/**
* Stylesheets to exclude from the "Unused CSS" feature.
*/
Expand Down Expand Up @@ -151,15 +191,24 @@ private static function get_defaults( $options = [] ) {
// https://perfmatters.io/docs/disable-woocommerce-cart-fragments-ajax/
$options['disable_woocommerce_cart_fragmentation'] = true;

// Resolve once per filter pass – this is read on essentially every front-end
// request, and both the defer and delay blocks below need it (NPPM-2934).
$reveal_above_header = self::has_immediate_above_header_prompts();
Comment thread
adekbadek marked this conversation as resolved.

// JS deferral.
if ( ! isset( $options['assets'] ) ) {
$options['assets'] = [];
}
$defer_js_exclusions = [
$defer_js_exclusions = [
'wp-includes',
'jwplayer.com', // This platform won't work if the JS is deferred.
'adsrvr.org', // This platform won't work if the JS is deferred.
];
// When the site has published above-header prompts, exclude the prompt reveal
// scripts from deferral too, so they execute as early as possible (NPPM-2934).
if ( $reveal_above_header ) {
$defer_js_exclusions = array_merge( $defer_js_exclusions, self::above_header_defer_exclusions() );
}
$options['assets']['defer_js'] = true;
if ( isset( $options['assets']['js_exclusions'] ) && is_array( $options['assets']['js_exclusions'] ) ) {
$options['assets']['js_exclusions'] = array_unique(
Expand All @@ -175,16 +224,20 @@ private static function get_defaults( $options = [] ) {

// JS delay.
$options['assets']['delay_js'] = true;
$delay_js_inclusions = self::scripts_to_delay();
if ( isset( $options['assets']['delay_js_inclusions'] ) && is_array( $options['assets']['delay_js_inclusions'] ) ) {
$options['assets']['delay_js_inclusions'] = array_unique(
array_merge(
$options['assets']['delay_js_inclusions'],
self::scripts_to_delay()
)
);
} else {
$options['assets']['delay_js_inclusions'] = self::scripts_to_delay();
$delay_js_inclusions = array_merge( $options['assets']['delay_js_inclusions'], $delay_js_inclusions );
}
// When the site has published above-header prompts, keep the scripts that reveal them
// out of the delay queue so the prompts appear immediately instead of after the first
// interaction. The subtraction runs on the merged list, not just on Newspack's own
// contribution: Perfmatters persists its delay list whenever its settings are saved
// through the UI, so on a configured site the stored list already contains the reveal
// scripts and merging alone would put them straight back (NPPM-2934).
if ( $reveal_above_header ) {
$delay_js_inclusions = array_diff( $delay_js_inclusions, self::above_header_reveal_scripts() );
}
$options['assets']['delay_js_inclusions'] = array_values( array_unique( $delay_js_inclusions ) );
$options['assets']['delay_timeout'] = true;
$options['assets']['fastclick'] = true;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
/**
* WordPress dependencies.
*/
import { __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis
import { Notice, __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis
import { useContext, useEffect, useState, Fragment } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { ENTER } from '@wordpress/keycodes';
Expand All @@ -17,7 +17,7 @@ import { moreVertical } from '@wordpress/icons';
import { Button, Card, CustomSelectControl, Modal, Router, TextControl, withWizardScreen } from '../../../../../../packages/components/src';
import CampaignManagementPopover from '../../../components/campaign-management-popover';
import SegmentGroup from '../../../components/segment-group';
import { dataForCampaignId } from '../utils';
import { dataForCampaignId, isAboveHeader } from '../utils';
import { CampaignsContext } from '../../../contexts';
import './style.scss';

Expand Down Expand Up @@ -119,6 +119,7 @@ const Campaigns = props => {
const allPrompts = useContext( CampaignsContext );
const prompts = filterByCampaign( allPrompts, campaignId );
const hasUnassigned = filterByCampaign( allPrompts, 'unassigned' ).length;
const hasPublishedAboveHeader = allPrompts.some( prompt => 'publish' === prompt.status && isAboveHeader( prompt ) );

useEffect( () => {
if ( modalVisible ) {
Expand Down Expand Up @@ -196,6 +197,14 @@ const Campaigns = props => {

return (
<Fragment>
{ hasPublishedAboveHeader && (
<Notice className="newspack-campaigns__above-header-notice" status="warning" isDismissible={ false }>
{ __(
'This site has published above-header prompts. These appear at the very top of the page and push the rest of the content down as they load, which can lower your Cumulative Layout Shift (CLS) score. To reduce the impact, keep above-header prompts short, or use an overlay placement instead.',
'newspack-plugin'
) }
</Notice>
) }
<Card headerActions noBorder>
<div className="newspack-campaigns__campaign-group__filter-group-actions">
<CustomSelectControl
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@
}
}

.newspack-campaigns__above-header-notice {
margin-bottom: 16px;
}

.newspack-card__buttons-prompt {
.newspack-button {
color: wp-colors.$gray-900;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php // phpcs:disable WordPress.Files.FileName.InvalidClassFileName
/**
* Newspack_Popups_Model stub for tests.
*
* The Perfmatters integration calls
* \Newspack_Popups_Model::has_published_above_header_prompts() guarded by
* method_exists(). newspack-popups is not loaded in this test suite, so this stub
* stands in for it and its return value is toggled via
* `Newspack_Popups_Model::$has_above_header`.
*
* @package Newspack\Tests
*/

if ( ! class_exists( 'Newspack_Popups_Model' ) ) {
/**
* Minimal Newspack_Popups_Model stub. Only the surface the integration touches.
*/
class Newspack_Popups_Model {
/**
* Stubbed detection result.
*
* @var bool
*/
public static $has_above_header = false;

/**
* Stubbed detection method.
*
* @return bool
*/
public static function has_published_above_header_prompts() {
return self::$has_above_header;
}
}
}
113 changes: 104 additions & 9 deletions plugins/newspack-plugin/tests/unit-tests/perfmatters.php
Original file line number Diff line number Diff line change
@@ -1,32 +1,127 @@
<?php

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Severity: suggestion

GitHub reports this PR as CONFLICTING. The merge-base predates NPPM-193 (the Perfmatters WooCommerce-scripts veto), which landed on main afterward and created this same test file with three WC-veto tests (test_vetoes_when_wc_content_present, test_passes_through_when_no_wc_content, test_ignore_defaults_passes_through). This branch independently created the file with the five above-header tests, so it's an add/add conflict. class-perfmatters.php auto-merges cleanly, so the veto source (maybe_keep_woocommerce_assets) survives — but those three tests live only in main's copy of this file, and resolving the conflict by keeping this branch's version wholesale would silently drop them, leaving the surviving method untested. When you rebase/merge main, union the two sets of tests rather than overwriting.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed the diagnosis: NPPM-193 (commit 5aa59b0) is on origin/main but not in this branch, so neither maybe_keep_woocommerce_assets nor the three WC-veto tests exist here yet. This is genuinely a merge to resolve, not something I can fix in-place – hand-adding the WC-veto tests to this file would fail (the veto method is absent on this branch) and would still collide on the eventual merge. Flagging for a human to run the git merge origin/main and union both test sets (the five above-header tests here + the three WC-veto tests from main) into one class. Leaving this thread open.

/**
* Tests for the Perfmatters integration WooCommerce veto.
* Tests the Perfmatters integration (NPPM-2934).
*
* @package Newspack
* @package Newspack\Tests
*/

use Newspack\Perfmatters;
use Newspack\WooCommerce_Content_Detector;

require_once __DIR__ . '/../mocks/newspack-popups-model-mock.php';

/**
* Test the perfmatters_disable_woocommerce_scripts callback.
* Tests the Perfmatters integration: above-header prompt handling and the
* WooCommerce veto.
*/
class Newspack_Test_Perfmatters extends WP_UnitTestCase {

/**
* Reset the detector memo before each test.
*/
public function setUp(): void {
parent::setUp();
public function set_up() {
parent::set_up();
WooCommerce_Content_Detector::reset_memo();
}

/**
* Reset the detector memo after each test.
* Reset the above-header flag and the detector memo after each test.
*/
public function tearDown(): void {
public function tear_down() {
\Newspack_Popups_Model::$has_above_header = false;
WooCommerce_Content_Detector::reset_memo();
parent::tearDown();
parent::tear_down();
}

/**
* Without above-header prompts, the prompt reveal scripts stay in the JS delay queue.
*/
public function test_reveal_scripts_delayed_without_above_header_prompts() {
\Newspack_Popups_Model::$has_above_header = false;

$options = Perfmatters::set_defaults( [] );

$this->assertContains( 'newspack-popups', $options['assets']['delay_js_inclusions'] );
$this->assertContains( 'window.newspack', $options['assets']['delay_js_inclusions'] );
$this->assertNotContains( 'newspack-popups', $options['assets']['js_exclusions'] );
$this->assertNotContains( 'newspack-plugin', $options['assets']['js_exclusions'] );
}

/**
* With published above-header prompts, the reveal scripts are removed from the JS
* delay queue and excluded from deferral so the prompts appear immediately.
*/
public function test_reveal_scripts_undelayed_with_above_header_prompts() {
\Newspack_Popups_Model::$has_above_header = true;

$options = Perfmatters::set_defaults( [] );

$this->assertNotContains( 'newspack-popups', $options['assets']['delay_js_inclusions'] );
$this->assertNotContains( 'window.newspack', $options['assets']['delay_js_inclusions'] );
$this->assertNotContains( 'newspack-plugin', $options['assets']['delay_js_inclusions'] );

$this->assertContains( 'newspack-popups', $options['assets']['js_exclusions'] );
$this->assertContains( 'newspack-plugin', $options['assets']['js_exclusions'] );
Comment thread
adekbadek marked this conversation as resolved.

// `window.newspack` is an inline token; deferral only applies to external <script src>
// files, so it is intentionally kept out of the defer exclusions even while present in
// the delay exclusions. Assert the asymmetry so the two lists cannot silently drift.
$this->assertNotContains( 'window.newspack', $options['assets']['js_exclusions'] );
}

/**
* Perfmatters persists its delay list whenever its settings are saved through the UI,
* so on a configured site the stored option already contains the reveal scripts. They
* must be subtracted from the merged list, not merely omitted from Newspack's own
* contribution – otherwise the merge puts them back and the prompts stay delayed on
* exactly the sites this targets.
*/
public function test_reveal_scripts_undelayed_when_already_in_saved_option() {
\Newspack_Popups_Model::$has_above_header = true;

$options = Perfmatters::set_defaults(
[
'assets' => [
'delay_js_inclusions' => [ 'newspack-popups', 'window.newspack', 'newspack-plugin', 'publisher-script' ],
],
]
);

$this->assertNotContains( 'newspack-popups', $options['assets']['delay_js_inclusions'] );
$this->assertNotContains( 'window.newspack', $options['assets']['delay_js_inclusions'] );
$this->assertNotContains( 'newspack-plugin', $options['assets']['delay_js_inclusions'] );

// The publisher's own entries survive.
$this->assertContains( 'publisher-script', $options['assets']['delay_js_inclusions'] );
}

/**
* Without above-header prompts, a saved delay list keeps the reveal scripts.
*/
public function test_saved_delay_list_is_preserved_without_above_header_prompts() {
\Newspack_Popups_Model::$has_above_header = false;

$options = Perfmatters::set_defaults(
[
'assets' => [
'delay_js_inclusions' => [ 'newspack-popups', 'publisher-script' ],
],
]
);

$this->assertContains( 'newspack-popups', $options['assets']['delay_js_inclusions'] );
$this->assertContains( 'publisher-script', $options['assets']['delay_js_inclusions'] );
}

/**
* Unrelated scripts remain delayed regardless of above-header prompts.
*/
public function test_other_scripts_still_delayed_with_above_header_prompts() {
\Newspack_Popups_Model::$has_above_header = true;

$options = Perfmatters::set_defaults( [] );

$this->assertContains( 'newspack-blocks', $options['assets']['delay_js_inclusions'] );
$this->assertContains( 'recaptcha', $options['assets']['delay_js_inclusions'] );
}

/**
Expand Down
Loading
Loading