From 9bd5fa7a2d81c560aae1b5324d442dedcc568f15 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Mon, 10 Aug 2026 02:27:06 -0400 Subject: [PATCH 1/3] Defer frontend PHP handlers until needed --- classes/Admin.php | 10 ++-- classes/Plugin/Core.php | 42 ++++++++++++++++- classes/Services/Notifications/Manager.php | 3 ++ classes/Services/Notifications/Provider.php | 6 +-- includes/legacy/class-popup-maker.php | 31 +++++++++++-- ...um-test-deferred-notification-provider.php | 26 +++++++++++ .../Notification_Manager_Loader_Test.php | 46 +++++++++++++++++++ tests/php/tests/PUM_Admin_Loader_Test.php | 6 +++ tests/php/tests/PUM_Deferred_Hooks_Test.php | 39 ++++++++++++++++ 9 files changed, 197 insertions(+), 12 deletions(-) create mode 100644 tests/php/fixtures/class-pum-test-deferred-notification-provider.php create mode 100644 tests/php/tests/Notification_Manager_Loader_Test.php create mode 100644 tests/php/tests/PUM_Deferred_Hooks_Test.php diff --git a/classes/Admin.php b/classes/Admin.php index c5c0693f2..96b4c268c 100644 --- a/classes/Admin.php +++ b/classes/Admin.php @@ -19,16 +19,20 @@ public static function init() { PUM_Admin_Notices::init(); } - PUM_Admin_Popups::init(); - PUM_Admin_Themes::init(); - if ( is_admin() ) { + PUM_Admin_Popups::init(); + PUM_Admin_Themes::init(); PUM_Admin_Subscribers::init(); PUM_Admin_Settings::init(); PUM_Admin_Tools::init(); PUM_Admin_Shortcode_UI::init(); PUM_Upsell::init(); PUM_Admin_Onboarding::init(); + } else { + // Preserve programmatic saves without loading the editor classes on every frontend request. + add_action( 'save_post', [ 'PUM_Admin_Popups', 'save' ], 10, 2 ); + add_filter( 'wp_insert_post_data', [ 'PUM_Admin_Popups', 'set_slug' ], 99, 2 ); + add_action( 'save_post', [ 'PUM_Admin_Themes', 'save' ], 10, 2 ); } add_filter( 'user_has_cap', [ __CLASS__, 'prevent_default_theme_deletion' ], 10, 3 ); diff --git a/classes/Plugin/Core.php b/classes/Plugin/Core.php index 653aedd89..ba4e7ad9c 100644 --- a/classes/Plugin/Core.php +++ b/classes/Plugin/Core.php @@ -20,6 +20,27 @@ */ final class Core extends \PopupMaker\Plugin\Container { + /** + * Events that require notification providers on frontend requests. + * + * @var string[] + */ + private const DEFERRED_NOTIFICATION_HOOKS = [ + 'popup_maker/update_version', + 'pum_alert_list', + 'pum_alert_dismissed', + 'save_post_popup', + 'save_post_pum_cta', + 'deleted_post', + 'trashed_post', + 'untrashed_post', + 'update_option_pum_form_conversion_count', + 'update_option_pum_total_conversion_count', + 'update_option_pum_bypass_adblockers', + 'activated_plugin', + 'deactivated_plugin', + ]; + /** * Initiate the plugin. * @@ -355,10 +376,29 @@ protected function init_services() { * filter before the Manager resolves the provider list. */ add_action( 'init', function () { - $this->get( 'notifications' )->init(); + if ( is_admin() ) { + $this->get( 'notifications' )->init(); + return; + } + + foreach ( self::DEFERRED_NOTIFICATION_HOOKS as $hook ) { + add_filter( $hook, [ $this, 'init_notifications_on_demand' ], PHP_INT_MIN ); + } }, 5 ); } + /** + * Boot notification providers when a frontend request reaches a relevant event. + * + * @param mixed $value Current filter value, if any. + * @return mixed + */ + public function init_notifications_on_demand( $value = null ) { + $this->get( 'notifications' )->init(); + + return $value; + } + /** * Get the options service. * diff --git a/classes/Services/Notifications/Manager.php b/classes/Services/Notifications/Manager.php index 5ea8fd07d..e448b10ca 100644 --- a/classes/Services/Notifications/Manager.php +++ b/classes/Services/Notifications/Manager.php @@ -67,6 +67,7 @@ public function init() { if ( $this->booted ) { return; } + $this->booted = true; $this->providers = $this->resolve_providers(); @@ -87,6 +88,8 @@ public function init() { * @return array */ public function get_providers() { + $this->init(); + return $this->providers; } diff --git a/classes/Services/Notifications/Provider.php b/classes/Services/Notifications/Provider.php index 48132ad58..25f67dca4 100644 --- a/classes/Services/Notifications/Provider.php +++ b/classes/Services/Notifications/Provider.php @@ -16,9 +16,9 @@ * entries to the `pum_alert_list` filter and optionally reacts to * dismissals, version changes, or other plugin events. * - * Providers are booted once during plugin load via - * `Notifications::init()` — each is given the opportunity to wire its own - * hooks inside its `init()` method. + * Providers are booted once via `Notifications::init()` — each is given the + * opportunity to wire its own hooks inside its `init()` method. Frontend + * requests may defer that boot until a notification-related event. * * @since 1.23.0 */ diff --git a/includes/legacy/class-popup-maker.php b/includes/legacy/class-popup-maker.php index 36b7c8541..fba424371 100644 --- a/includes/legacy/class-popup-maker.php +++ b/includes/legacy/class-popup-maker.php @@ -169,9 +169,7 @@ public function init() { PUM_Utils_Upgrades::instance(); PUM_Newsletters::init(); PUM_Integrations::init(); - PUM_Privacy::init(); - - PUM_Utils_Alerts::init(); + $this->register_deferred_hooks(); PUM_Shortcode_Popup::init(); PUM_Shortcode_PopupTrigger::init(); @@ -179,11 +177,34 @@ public function init() { PUM_Shortcode_PopupCookie::init(); PUM_Shortcode_CallToAction::init(); - PUM_Telemetry::init(); - new PUM_Extensions(); } + /** + * Register hooks whose handlers are not needed during normal frontend bootstrap. + * + * String callbacks preserve the existing hooks while allowing WordPress to + * autoload each implementation only when its hook runs. + * + * @return void + */ + private function register_deferred_hooks() { + add_filter( 'wp_privacy_personal_data_exporters', [ 'PUM_Privacy', 'register_exporter' ], 10 ); + add_filter( 'wp_privacy_personal_data_erasers', [ 'PUM_Privacy', 'register_erasers' ], 10 ); + add_action( 'admin_init', [ 'PUM_Privacy', 'privacy_policy_content' ], 20 ); + add_action( 'pum_save_popup', [ 'PUM_Privacy', 'clear_cookie_list' ] ); + + add_action( 'admin_init', [ 'PUM_Utils_Alerts', 'hooks' ] ); + add_action( 'admin_init', [ 'PUM_Utils_Alerts', 'php_handler' ] ); + add_action( 'wp_ajax_pum_alerts_action', [ 'PUM_Utils_Alerts', 'ajax_handler' ] ); + add_filter( 'pum_alert_list', [ 'PUM_Utils_Alerts', 'translation_request' ], 10 ); + add_action( 'admin_menu', [ 'PUM_Utils_Alerts', 'append_alert_count' ], 999 ); + + add_action( 'pum_daily_scheduled_events', [ 'PUM_Telemetry', 'track_check' ] ); + add_filter( 'pum_alert_list', [ 'PUM_Telemetry', 'optin_alert' ] ); + add_action( 'pum_alert_dismissed', [ 'PUM_Telemetry', 'optin_alert_check' ], 10, 2 ); + } + /** * Returns true when debug mode is enabled. * diff --git a/tests/php/fixtures/class-pum-test-deferred-notification-provider.php b/tests/php/fixtures/class-pum-test-deferred-notification-provider.php new file mode 100644 index 000000000..17fabfa4a --- /dev/null +++ b/tests/php/fixtures/class-pum-test-deferred-notification-provider.php @@ -0,0 +1,26 @@ + 'deferred_test_provider' ]; + + return $alerts; + } +} diff --git a/tests/php/tests/Notification_Manager_Loader_Test.php b/tests/php/tests/Notification_Manager_Loader_Test.php new file mode 100644 index 000000000..704468c8f --- /dev/null +++ b/tests/php/tests/Notification_Manager_Loader_Test.php @@ -0,0 +1,46 @@ +init(); + return $alerts; + }; + + $this->assertFalse( is_admin() ); + add_filter( 'pum_alert_list', $loader, PHP_INT_MIN ); + + add_filter( + 'popup_maker/notification_providers', + static function ( $providers ) use ( $provider ) { + $providers[] = $provider; + return $providers; + } + ); + + $alerts = apply_filters( 'pum_alert_list', [] ); + $codes = wp_list_pluck( $alerts, 'code' ); + + $this->assertContains( 'deferred_test_provider', $codes ); + $this->assertContains( $provider, $manager->get_providers() ); + } +} diff --git a/tests/php/tests/PUM_Admin_Loader_Test.php b/tests/php/tests/PUM_Admin_Loader_Test.php index 87d1eec1c..b34379f5c 100644 --- a/tests/php/tests/PUM_Admin_Loader_Test.php +++ b/tests/php/tests/PUM_Admin_Loader_Test.php @@ -50,12 +50,18 @@ public function tearDown(): void { } /** + * Frontend bootstrap defers the heavy editor classes until their save hooks run. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * * @return void */ public function test_frontend_loads_only_cross_context_admin_hooks() { $this->assertFalse( is_admin() ); $this->assertSame( 10, has_action( 'save_post', [ 'PUM_Admin_Popups', 'save' ] ) ); $this->assertSame( 10, has_action( 'save_post', [ 'PUM_Admin_Themes', 'save' ] ) ); + $this->assertSame( 99, has_filter( 'wp_insert_post_data', [ 'PUM_Admin_Popups', 'set_slug' ] ) ); $this->assertSame( 10, has_action( 'enqueue_block_assets', [ 'PUM_Admin_BlockEditor', 'register_block_assets' ] ) ); $this->assertFalse( has_action( 'admin_menu', [ 'PUM_Admin_Pages', 'register_pages' ] ) ); $this->assertFalse( has_action( 'wp_ajax_pum_object_search', [ 'PUM_Admin_Ajax', 'object_search' ] ) ); diff --git a/tests/php/tests/PUM_Deferred_Hooks_Test.php b/tests/php/tests/PUM_Deferred_Hooks_Test.php new file mode 100644 index 000000000..1a7dba4c8 --- /dev/null +++ b/tests/php/tests/PUM_Deferred_Hooks_Test.php @@ -0,0 +1,39 @@ +assertFalse( class_exists( 'PUM_Privacy', false ) ); + $this->assertFalse( class_exists( 'PUM_Utils_Alerts', false ) ); + $this->assertFalse( class_exists( 'PUM_Telemetry', false ) ); + + $this->assertSame( 10, has_filter( 'wp_privacy_personal_data_exporters', [ 'PUM_Privacy', 'register_exporter' ] ) ); + $this->assertSame( 10, has_filter( 'wp_privacy_personal_data_erasers', [ 'PUM_Privacy', 'register_erasers' ] ) ); + $this->assertSame( 20, has_action( 'admin_init', [ 'PUM_Privacy', 'privacy_policy_content' ] ) ); + $this->assertSame( 10, has_action( 'pum_save_popup', [ 'PUM_Privacy', 'clear_cookie_list' ] ) ); + + $this->assertSame( 10, has_action( 'admin_init', [ 'PUM_Utils_Alerts', 'hooks' ] ) ); + $this->assertSame( 10, has_action( 'admin_init', [ 'PUM_Utils_Alerts', 'php_handler' ] ) ); + $this->assertSame( 10, has_action( 'wp_ajax_pum_alerts_action', [ 'PUM_Utils_Alerts', 'ajax_handler' ] ) ); + $this->assertSame( 10, has_filter( 'pum_alert_list', [ 'PUM_Utils_Alerts', 'translation_request' ] ) ); + $this->assertSame( 999, has_action( 'admin_menu', [ 'PUM_Utils_Alerts', 'append_alert_count' ] ) ); + + $this->assertSame( 10, has_action( 'pum_daily_scheduled_events', [ 'PUM_Telemetry', 'track_check' ] ) ); + $this->assertSame( 10, has_filter( 'pum_alert_list', [ 'PUM_Telemetry', 'optin_alert' ] ) ); + $this->assertSame( 10, has_action( 'pum_alert_dismissed', [ 'PUM_Telemetry', 'optin_alert_check' ] ) ); + } +} From 9f7d898bb65aa8f57f9946864f3d54bed90450b6 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Wed, 12 Aug 2026 05:02:07 -0400 Subject: [PATCH 2/3] refactor(notifications): move deferred boot into Manager with filterable hooks Per review feedback: the deferral mechanism now lives in the Notifications Manager itself instead of Plugin\Core, and the trigger hook list is extensible via the popup_maker/notifications/deferred_boot_hooks filter so Pro, Pro+, and legacy extensions can register their own. get_providers() remains the universal lazy-boot fallback, so unregistered consumers still work. --- classes/Plugin/Core.php | 48 ++------------- classes/Services/Notifications/Manager.php | 58 +++++++++++++++++++ .../Notification_Manager_Loader_Test.php | 42 ++++++++++++-- 3 files changed, 99 insertions(+), 49 deletions(-) diff --git a/classes/Plugin/Core.php b/classes/Plugin/Core.php index ba4e7ad9c..973cd0b05 100644 --- a/classes/Plugin/Core.php +++ b/classes/Plugin/Core.php @@ -20,27 +20,6 @@ */ final class Core extends \PopupMaker\Plugin\Container { - /** - * Events that require notification providers on frontend requests. - * - * @var string[] - */ - private const DEFERRED_NOTIFICATION_HOOKS = [ - 'popup_maker/update_version', - 'pum_alert_list', - 'pum_alert_dismissed', - 'save_post_popup', - 'save_post_pum_cta', - 'deleted_post', - 'trashed_post', - 'untrashed_post', - 'update_option_pum_form_conversion_count', - 'update_option_pum_total_conversion_count', - 'update_option_pum_bypass_adblockers', - 'activated_plugin', - 'deactivated_plugin', - ]; - /** * Initiate the plugin. * @@ -369,36 +348,17 @@ protected function init_services() { $form_conversion_tracking->init(); /* - * Defer notifications orchestrator init until WordPress's `init` + * Defer notification bootstrap until WordPress's `init` * action. Core loads on plugins_loaded@11, but addons (Pro, Pro+, * integrations) load at priority 12+ and need a window to register - * their own providers via the `popup_maker/notification_providers` - * filter before the Manager resolves the provider list. + * their own providers and deferred trigger hooks before the Manager + * resolves the provider list or registers frontend lazy boot hooks. */ add_action( 'init', function () { - if ( is_admin() ) { - $this->get( 'notifications' )->init(); - return; - } - - foreach ( self::DEFERRED_NOTIFICATION_HOOKS as $hook ) { - add_filter( $hook, [ $this, 'init_notifications_on_demand' ], PHP_INT_MIN ); - } + $this->get( 'notifications' )->register_lazy_boot(); }, 5 ); } - /** - * Boot notification providers when a frontend request reaches a relevant event. - * - * @param mixed $value Current filter value, if any. - * @return mixed - */ - public function init_notifications_on_demand( $value = null ) { - $this->get( 'notifications' )->init(); - - return $value; - } - /** * Get the options service. * diff --git a/classes/Services/Notifications/Manager.php b/classes/Services/Notifications/Manager.php index e448b10ca..8c7bd0409 100644 --- a/classes/Services/Notifications/Manager.php +++ b/classes/Services/Notifications/Manager.php @@ -79,6 +79,64 @@ public function init() { } } + /** + * Boot notifications immediately in wp-admin or defer them to relevant frontend hooks. + * + * @return void + */ + public function register_lazy_boot() { + if ( is_admin() ) { + $this->init(); + return; + } + + foreach ( $this->get_deferred_boot_hooks() as $hook ) { + add_filter( $hook, [ $this, 'boot_on_demand' ], PHP_INT_MIN ); + } + } + + /** + * Get hooks that can trigger lazy notification boot on frontend requests. + * + * Extensions (Pro, Pro+, legacy) can append their own trigger hooks via the + * `popup_maker/notifications/deferred_boot_hooks` filter. Any consumer that + * calls get_providers() boots the manager regardless, so this filter is an + * optimization and not a correctness requirement. + * + * @return string[] + */ + protected function get_deferred_boot_hooks() { + $defaults = [ + 'popup_maker/update_version', + 'pum_alert_list', + 'pum_alert_dismissed', + 'save_post_popup', + 'save_post_pum_cta', + 'deleted_post', + 'trashed_post', + 'untrashed_post', + 'update_option_pum_form_conversion_count', + 'update_option_pum_total_conversion_count', + 'update_option_pum_bypass_adblockers', + 'activated_plugin', + 'deactivated_plugin', + ]; + + return apply_filters( 'popup_maker/notifications/deferred_boot_hooks', $defaults ); + } + + /** + * Boot notification providers when a frontend request reaches a relevant hook. + * + * @param mixed $value Current filter value, if any. + * @return mixed + */ + public function boot_on_demand( $value = null ) { + $this->init(); + + return $value; + } + /** * Currently booted providers. * diff --git a/tests/php/tests/Notification_Manager_Loader_Test.php b/tests/php/tests/Notification_Manager_Loader_Test.php index 704468c8f..9cb1d6d2a 100644 --- a/tests/php/tests/Notification_Manager_Loader_Test.php +++ b/tests/php/tests/Notification_Manager_Loader_Test.php @@ -21,13 +21,8 @@ class Notification_Manager_Loader_Test extends WP_UnitTestCase { public function test_alert_filter_boots_deferred_providers_in_current_iteration() { $manager = new \PopupMaker\Services\Notifications\Manager( \PopupMaker\plugin() ); $provider = new PUM_Test_Deferred_Notification_Provider(); - $loader = static function ( $alerts ) use ( $manager ) { - $manager->init(); - return $alerts; - }; $this->assertFalse( is_admin() ); - add_filter( 'pum_alert_list', $loader, PHP_INT_MIN ); add_filter( 'popup_maker/notification_providers', @@ -36,6 +31,7 @@ static function ( $providers ) use ( $provider ) { return $providers; } ); + $manager->register_lazy_boot(); $alerts = apply_filters( 'pum_alert_list', [] ); $codes = wp_list_pluck( $alerts, 'code' ); @@ -43,4 +39,40 @@ static function ( $providers ) use ( $provider ) { $this->assertContains( 'deferred_test_provider', $codes ); $this->assertContains( $provider, $manager->get_providers() ); } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @return void + */ + public function test_custom_deferred_boot_hook_boots_the_manager_on_frontend() { + $manager = new \PopupMaker\Services\Notifications\Manager( \PopupMaker\plugin() ); + $provider = new PUM_Test_Deferred_Notification_Provider(); + $custom_hook = 'pum_test_custom_deferred_notification_boot'; + + $this->assertFalse( is_admin() ); + + add_filter( + 'popup_maker/notification_providers', + static function ( $providers ) use ( $provider ) { + $providers[] = $provider; + return $providers; + } + ); + + add_filter( + 'popup_maker/notifications/deferred_boot_hooks', + static function ( $hooks ) use ( $custom_hook ) { + $hooks[] = $custom_hook; + return $hooks; + } + ); + + $manager->register_lazy_boot(); + + $this->assertSame( 'original-value', apply_filters( $custom_hook, 'original-value' ) ); + $this->assertSame( PHP_INT_MIN + 1, has_filter( 'pum_alert_list', [ $provider, 'add_alert' ] ) ); + $this->assertContains( $provider, $manager->get_providers() ); + } } From fd07bc776a3f6938f66c27820ad6161f28692ce5 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Wed, 12 Aug 2026 18:49:49 -0400 Subject: [PATCH 3/3] fix(notifications): boot immediately when a deferred hook already fired An upgrade dispatches popup_maker/update_version during plugins_loaded, before init@5 registers the lazy boot filters. Check did_action() and did_filter() for each deferred hook and boot the manager immediately if the event already fired this request. Adds a regression test plus a Core-wiring assertion through the booted plugin. --- classes/Services/Notifications/Manager.php | 8 +++ .../Notification_Manager_Loader_Test.php | 56 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/classes/Services/Notifications/Manager.php b/classes/Services/Notifications/Manager.php index 8c7bd0409..1a66b9cf7 100644 --- a/classes/Services/Notifications/Manager.php +++ b/classes/Services/Notifications/Manager.php @@ -91,6 +91,14 @@ public function register_lazy_boot() { } foreach ( $this->get_deferred_boot_hooks() as $hook ) { + if ( did_action( $hook ) || did_filter( $hook ) ) { + // The event already fired earlier in this request — e.g. an + // upgrade dispatches popup_maker/update_version during + // plugins_loaded, before this registration runs on init. + $this->init(); + return; + } + add_filter( $hook, [ $this, 'boot_on_demand' ], PHP_INT_MIN ); } } diff --git a/tests/php/tests/Notification_Manager_Loader_Test.php b/tests/php/tests/Notification_Manager_Loader_Test.php index 9cb1d6d2a..08d94df6a 100644 --- a/tests/php/tests/Notification_Manager_Loader_Test.php +++ b/tests/php/tests/Notification_Manager_Loader_Test.php @@ -75,4 +75,60 @@ static function ( $hooks ) use ( $custom_hook ) { $this->assertSame( PHP_INT_MIN + 1, has_filter( 'pum_alert_list', [ $provider, 'add_alert' ] ) ); $this->assertContains( $provider, $manager->get_providers() ); } + + /** + * An upgrade fires popup_maker/update_version during plugins_loaded, + * before init@5 registers lazy boot — the manager must boot immediately + * when a deferred hook already fired earlier in the request. + * + * @return void + */ + public function test_lazy_boot_inits_immediately_when_deferred_hook_already_fired() { + $manager = new \PopupMaker\Services\Notifications\Manager( \PopupMaker\plugin() ); + $provider = new PUM_Test_Deferred_Notification_Provider(); + + $this->assertFalse( is_admin() ); + + add_filter( + 'popup_maker/notification_providers', + static function ( $providers ) use ( $provider ) { + $providers[] = $provider; + return $providers; + } + ); + + // Simulate the upgrade action firing before register_lazy_boot runs. + do_action( 'popup_maker/update_version', '1.0.0', '0.9.0' ); + + $manager->register_lazy_boot(); + + $this->assertContains( $provider, $manager->get_providers() ); + } + + /** + * Core must wire the Manager's lazy boot on init — verify through the + * real booted plugin rather than a locally constructed manager. + * + * @return void + */ + public function test_core_registers_manager_lazy_boot_on_init() { + $manager = \PopupMaker\plugin( 'notifications' ); + + // Core's wiring results in one of two valid states: deferred boot + // filters registered, or an immediate boot because a deferred hook + // (e.g. popup_maker/update_version on a fresh install) already fired. + $deferred = false !== has_filter( 'pum_alert_list', [ $manager, 'boot_on_demand' ] ); + + $booted_prop = new ReflectionProperty( $manager, 'booted' ); + + if ( PHP_VERSION_ID < 80100 ) { + // Required before PHP 8.1, deprecated no-op on PHP 8.5+. + $booted_prop->setAccessible( true ); + } + + $this->assertTrue( + $deferred || $booted_prop->getValue( $manager ), + 'Core should register the real manager for deferred boot on frontend requests.' + ); + } }