diff --git a/inc/class-current.php b/inc/class-current.php index 5777318f..d68a46fc 100644 --- a/inc/class-current.php +++ b/inc/class-current.php @@ -239,6 +239,33 @@ public function load_currents(): void { return; } + /* + * `get_query_var()` reads from the global `$wp_query`, which + * WordPress only populates during `parse_query` (just before + * `wp` fires). The lazy-load entry path added in PR #1118 can + * call `load_currents()` from `Site::is_customer_allowed()` / + * `Membership::is_customer_allowed()` while still on + * `plugins_loaded` — for example when the wu-ajax pipeline + * (`Light_Ajax::process_light_ajax`) dispatches + * `wu_ajax_wu_switch_template` at `plugins_loaded` priority 20 + * and the handler reads `is_customer_allowed()`. At that point + * `$wp_query` is still null and `get_query_var()` fatals with + * `Call to a member function get() on null` on PHP 8+, which + * crashes the AJAX handler with a 500 — surfacing in the + * customer-panel template-switching UI as a generic + * "A network error occurred. Please check your connection and + * try again." banner. + * + * Skip the pretty-URL hash overrides when we know the query + * cannot have been parsed yet. The wu-ajax pipeline is a flat + * `?wu-ajax=1` POST with no rewrite-driven `site_hash` / + * `membership_hash` query vars, so there is nothing to lose + * by skipping them here. Frontend pretty-URL requests still + * hit `load_currents()` via the `wp` action where `$wp_query` + * is populated and these reads succeed normally. + */ + $query_vars_ready = did_action('parse_query') && isset($GLOBALS['wp_query']) && $GLOBALS['wp_query'] instanceof \WP_Query; + $site = false; /** @@ -250,7 +277,9 @@ public function load_currents(): void { */ $site_url_param = self::param_key('site'); - $site_hash = wu_request($site_url_param, get_query_var('site_hash')); + $site_hash_default = $query_vars_ready ? get_query_var('site_hash') : ''; + + $site_hash = wu_request($site_url_param, $site_hash_default); $site_from_url = wu_get_site_by_hash($site_hash); @@ -296,7 +325,9 @@ public function load_currents(): void { */ $membership_url_param = self::param_key('membership'); - $membership_hash = wu_request($membership_url_param, get_query_var('membership_hash')); + $membership_hash_default = $query_vars_ready ? get_query_var('membership_hash') : ''; + + $membership_hash = wu_request($membership_url_param, $membership_hash_default); if ($membership_hash) { $this->membership_set_via_request = true; diff --git a/tests/WP_Ultimo/Current_Test.php b/tests/WP_Ultimo/Current_Test.php index f3598e24..2d574fc6 100644 --- a/tests/WP_Ultimo/Current_Test.php +++ b/tests/WP_Ultimo/Current_Test.php @@ -198,6 +198,62 @@ protected function set_loaded_state($loaded): void { $property->setValue($this->current, $loaded); } + /** + * Regression test for the wu-ajax `plugins_loaded` fatal. + * + * The light-ajax pipeline (`Light_Ajax::process_light_ajax`) dispatches + * `wu_ajax_*` handlers at `plugins_loaded` priority 20. The handler for + * `wu_switch_template` calls `Site::is_customer_allowed()`, which (after + * the lazy-load entry from PR #1118) calls `Current::load_currents()` + * before the `wp` action has fired. At that point the global + * `$wp_query` is null, and any `get_query_var()` call inside + * `load_currents()` throws `Call to a member function get() on null` + * on PHP 8+ — surfacing in the customer-panel template-switching UI as + * a generic "A network error occurred" banner because the AJAX call + * died with a 500 instead of returning JSON. + * + * This test temporarily nulls `$GLOBALS['wp_query']` to simulate the + * early-call condition, then asserts `load_currents()` runs to + * completion without fataling. The hash-override branches must be + * skipped via the `$query_vars_ready` guard so the lazy-load can + * still populate the customer/membership fields. + */ + public function test_load_currents_survives_when_wp_query_is_null(): void { + + set_current_screen('front'); + + $user_id = $this->factory()->user->create(['role' => 'subscriber']); + + wu_create_customer( + [ + 'user_id' => $user_id, + 'email_address' => 'current-no-wp-query@example.com', + ] + ); + + wp_set_current_user($user_id); + + $this->current->set_site(null); + $this->current->set_customer(null); + $this->current->set_membership(null); + $this->set_loaded_state(false); + + // Save and null out the global wp_query to simulate the + // plugins_loaded entry path where it has not yet been set up. + $saved_wp_query = $GLOBALS['wp_query'] ?? null; + $GLOBALS['wp_query'] = null; + + try { + $this->current->load_currents(); + + // Sanity assertion — if get_query_var() had been called we + // would have fataled before reaching this line. + $this->assertTrue(true, 'load_currents survived a null $wp_query.'); + } finally { + $GLOBALS['wp_query'] = $saved_wp_query; + } + } + /** * Test param_key returns expected defaults. */