Commit 7bd06f418fe for woocommerce
commit 7bd06f418fef0db5d320d8aafec399e6daa937c5
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Wed Oct 7 11:55:04 2026 +0300
Fix cart and checkout crash when an address field filter recalculates totals (#69129)
* fix(cart): drop shipping a nested totals run leaves behind
WC_Cart::calculate_shipping() cleared the shipping totals and methods,
then asked show_shipping() whether the address was ready. A filter run
by that check can calculate the totals again, for example a checkout
field plugin that reads the cart total to decide whether a field
shows. When the nested run judged the address ready and the outer
check did not, the outer call returned early with the nested run's
methods, so the cart charged shipping for an address missing its
postcode while telling the shopper shipping would be calculated at
checkout.
This happens in the Store API context today. While the country locale
is being built, the innermost nested has_full_shipping_address() call
applies no rules and reports the address complete.
Clear the shipping totals again after the readiness check, so only the
outer call's own result counts. The first clear stays, so filters
running during the check still see no shipping.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(cart): stop show_shipping() recursing through field filters
With "Hide shipping costs until an address is entered" on, the classic
cart branch of WC_Cart::show_shipping() reads the filtered shipping
address and checkout fields to learn whether the state and postcode
are required. Extensions compute those fields from cart state. Custom
Checkout Fields for WooCommerce, for example, calls calculate_totals()
from its woocommerce_default_address_fields callback when a field has
a minimum or maximum cart amount. The totals calculation checks
shipping again, which reads the fields again, until PHP runs out of
memory. The cart and checkout pages then show a fatal error. The same
loop happens on WooCommerce 11.1.2, before the recent country-locale
changes.
Mark the cart while show_shipping() reads the fields. A nested call
made during that read uses has_full_shipping_address(), the country
locale check the Store API already uses, instead of reading the fields
again. The nested totals run still completes, so the extension gets a
real total.
The outer classic check keeps reading the fields. #55804 moved the
classic cart to the locale check in 9.8, and stores whose field
editors made the postcode optional stopped charging shipping (#57463)
until #57674 brought the field reads back. A new test pins that: when
a field filter makes the postcode optional and recalculates the
totals, the cart still charges shipping. When the nested locale check
is more lenient than the outer field check, the previous commit keeps
its shipping out of the cart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(changelog): add entry for show_shipping() recursion fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(cart): let a once-nested show_shipping() read the address fields
The previous commit stopped the recursion by sending every
show_shipping() call nested inside the classic field read to the
country locale check. That changed the answer for extensions that
recalculate the totals from a field filter behind their own re-entry
guard, which worked before: their nested call read the fields and
agreed with the outer call. The locale check disagrees with the fields
when the city is empty, when the shipping calculator filters turn off
the state or postcode, when a field filter changes or removes the state
or postcode, or when the locale hides a required field.
calculate_shipping() discards the nested run's shipping, but a
standalone show_shipping() call, as the cart totals and checkout review
templates make, kept it: the page showed the flat rate next to a total
that left it out.
Count the nested field reads instead. A call nested once reads the
fields, as before, so guarded extensions see no change. Only a call
nested twice, which only an unguarded filter reaches, uses the locale
check, so the reads still stop.
Replace the test that pinned the locale answer for the first nested
call with tests for each nesting level and for a standalone call after
a guarded and an unguarded recalculation. Make the AE and
optional-postcode tests recalculate without a guard so they still reach
the locale check, and raise the recursion tests' safety cap so a
regression overshoots the bound clearly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(changelog): describe the shipping charged for incomplete addresses
Say what merchants saw: shipping charged before the address was
complete, when an extension recalculates the cart from an address
field filter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: Keep the nested-check test's required postcode visible
#69139 made show_shipping() skip an address field whose locale entry
is hidden => true, even when a filter marks it required. The nested
calculate_totals() test uses the AE locale, which hides the postcode,
and its woocommerce_shipping_fields filter only set required => true.
After merging trunk, the outer check skipped the postcode and
calculated shipping, so the test failed.
Have the filter also set hidden => false, so the fields genuinely
require a visible postcode, which is what the test means to exercise.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: Consolidate nested shipping checks
The nesting tests exercise the same setup at successive recursion levels.
Combine their assertions in one test and inline the shared helper so the
full sequence can be read together without repeating the setup.
Clarify why the shipping check allows one nested address field read
before falling back to the country locale check.
* test: Cover direct shipping total resets
Address field filters can leave shipping totals and taxes behind while
shipping readiness is checked. The totals-based regression tests hide
missing resets because WC_Cart_Totals replaces those values later.
Assert cleared state on entry to the field filter and after
calculate_shipping() rejects an incomplete address. Mutation testing
now detects removal of either reset and changes to the reset values.
Refs #69129
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-show-shipping-address-fields-recursion b/plugins/woocommerce/changelog/fix-show-shipping-address-fields-recursion
new file mode 100644
index 00000000000..4532faeb9d9
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-show-shipping-address-fields-recursion
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix cart and checkout crashes, and shipping charged for an incomplete address, when an extension recalculates the cart from an address field.
diff --git a/plugins/woocommerce/includes/class-wc-cart.php b/plugins/woocommerce/includes/class-wc-cart.php
index 2403b92af51..05ef116cb17 100644
--- a/plugins/woocommerce/includes/class-wc-cart.php
+++ b/plugins/woocommerce/includes/class-wc-cart.php
@@ -39,6 +39,13 @@ class WC_Cart extends WC_Legacy_Cart {
*/
public $cart_context = 'shortcode';
+ /**
+ * How many show_shipping() calls are reading the shipping address fields, so a field filter that recalculates the totals cannot nest the reads without limit.
+ *
+ * @var int
+ */
+ private $shipping_address_field_reads = 0;
+
/**
* Contains an array of cart items.
*
@@ -1645,14 +1652,14 @@ class WC_Cart extends WC_Legacy_Cart {
* @return array
*/
public function calculate_shipping() {
- // Reset totals.
- $this->set_shipping_total( 0 );
- $this->set_shipping_tax( 0 );
- $this->set_shipping_taxes( array() );
- $this->shipping_methods = array();
- $this->has_calculated_shipping = false;
+ $this->clear_shipping_totals();
+
+ $ready = $this->needs_shipping() && $this->show_shipping();
+
+ // A filter run by show_shipping() can calculate the totals again and leave that nested run's shipping behind.
+ $this->clear_shipping_totals();
- if ( ! $this->needs_shipping() || ! $this->show_shipping() ) {
+ if ( ! $ready ) {
return $this->shipping_methods;
}
@@ -1675,6 +1682,17 @@ class WC_Cart extends WC_Legacy_Cart {
return $this->shipping_methods;
}
+ /**
+ * Reset the shipping totals, taxes and methods to none calculated.
+ */
+ private function clear_shipping_totals(): void {
+ $this->set_shipping_total( 0 );
+ $this->set_shipping_tax( 0 );
+ $this->set_shipping_taxes( array() );
+ $this->shipping_methods = array();
+ $this->has_calculated_shipping = false;
+ }
+
/**
* Given a set of packages with rates, get the chosen ones only.
*
@@ -1872,13 +1890,20 @@ class WC_Cart extends WC_Legacy_Cart {
return apply_filters( 'woocommerce_cart_ready_to_calc_shipping', true );
}
- if ( 'shortcode' === $this->cart_context ) {
+ // Calling calculate_totals() from an address field filter, such as woocommerce_default_address_fields, triggers show_shipping() again.
+ // Allow one nested field read for compatibility, then use the locale-based check below to prevent unbounded recursion.
+ if ( 'shortcode' === $this->cart_context && $this->shipping_address_field_reads < 2 ) {
$country = $this->get_customer()->get_shipping_country();
if ( ! $country ) {
return false;
}
- $country_fields = WC()->countries->get_address_fields( $country, 'shipping_' );
- $checkout_fields = WC()->checkout()->get_checkout_fields();
+ ++$this->shipping_address_field_reads;
+ try {
+ $country_fields = WC()->countries->get_address_fields( $country, 'shipping_' );
+ $checkout_fields = WC()->checkout()->get_checkout_fields();
+ } finally {
+ --$this->shipping_address_field_reads;
+ }
/**
* Filter to not require shipping state for shipping calculation, even if it is required at checkout.
diff --git a/plugins/woocommerce/includes/class-wc-customer.php b/plugins/woocommerce/includes/class-wc-customer.php
index 1800cc4e2ca..54cd5cdc649 100644
--- a/plugins/woocommerce/includes/class-wc-customer.php
+++ b/plugins/woocommerce/includes/class-wc-customer.php
@@ -285,7 +285,7 @@ class WC_Customer extends WC_Legacy_Customer {
* This method uses the current country's locale to determine if a field is required, or falls back to the default
* locale if there's no country-specific setting for that field.
*
- * This method is only used internally by StoreAPI, and not by the classic/shortcode checkout.
+ * The Store API uses this check. The classic cart uses it only for a show_shipping() call nested twice inside its own read of the shipping address fields.
*
* @since 9.8.0
* @return bool Whether the customer has a full shipping address (city, state, postcode, country).
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
index f2f7a221004..187f0a80009 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
@@ -49,6 +49,8 @@ class WC_Cart_Test extends \WC_Unit_Test_Case {
if ( null !== $this->original_shipping_address ) {
WC()->customer->set_props( $this->original_shipping_address );
$this->original_shipping_address = null;
+ // The checkout fields were built while the test's filters were attached.
+ $this->clear_checkout_fields();
}
// The parent teardown only clears chosen_shipping_methods, through
@@ -1022,6 +1024,301 @@ class WC_Cart_Test extends \WC_Unit_Test_Case {
WC()->cart->cart_context = 'shortcode'; // Reset to default.
}
+ /**
+ * @testdox show_shipping() does not recurse when an address field filter recalculates the cart totals.
+ *
+ * @dataProvider provide_address_field_filters
+ *
+ * @param string $hook Address field filter that recalculates the totals.
+ */
+ public function test_show_shipping_does_not_recurse_when_an_address_field_filter_recalculates_totals( string $hook ): void {
+ $this->add_product_for_an_address_without_postcode();
+ $calls = 0;
+ add_filter(
+ $hook,
+ function ( $fields ) use ( &$calls ) {
+ ++$calls;
+ // Stop a runaway recursion well above the bounded count, so a regression fails the assertion below instead of exhausting the process.
+ if ( $calls <= 30 ) {
+ WC()->cart->calculate_totals();
+ }
+ return $fields;
+ }
+ );
+
+ $result = WC()->cart->show_shipping();
+
+ $this->assertLessThanOrEqual( 10, $calls, "The {$hook} filter should not run again for every nested shipping check." );
+ $this->assertFalse( $result, 'A missing postcode should still hide shipping costs.' );
+ }
+
+ /**
+ * Address field filters that run while show_shipping() reads the shipping address fields.
+ *
+ * @return array<string, array<string>>
+ */
+ public function provide_address_field_filters(): array {
+ return array(
+ 'default address fields' => array( 'woocommerce_default_address_fields' ),
+ 'shipping fields' => array( 'woocommerce_shipping_fields' ),
+ 'billing fields' => array( 'woocommerce_billing_fields' ),
+ );
+ }
+
+ /**
+ * @testdox show_shipping() reads filtered fields for one nested call, then uses the country locale to stop further nesting.
+ */
+ public function test_show_shipping_limits_nested_field_reads_before_checking_the_country_locale(): void {
+ $this->add_product_for_an_address_without_postcode();
+ $answers = array();
+ $level = 1;
+ $calls = 0;
+ add_filter(
+ 'woocommerce_shipping_fields',
+ function ( $fields ) use ( &$answers, &$level, &$calls ) {
+ $fields['shipping_postcode']['required'] = false;
+ // Stop a runaway recursion so a regression fails an assertion instead of exhausting the process.
+ if ( ++$calls <= 30 ) {
+ $nested_level = ++$level;
+ $answer = WC()->cart->show_shipping();
+ --$level;
+ $answers[ $nested_level ][] = $answer;
+ }
+ return $fields;
+ }
+ );
+
+ $answers[1] = WC()->cart->show_shipping();
+
+ $this->assertTrue( $answers[1], 'The outer call should apply the shipping fields filter that makes the postcode optional.' );
+ $this->assertSame( array( true ), array_unique( $answers[2] ), 'A call nested once should read the same fields and answer like the outer call.' );
+ $this->assertSame( array( false ), array_unique( $answers[3] ), 'A call nested twice should require the postcode, as the US locale does.' );
+ $this->assertArrayNotHasKey( 4, $answers, 'The nesting should stop at the call that checks the country locale.' );
+ }
+
+ /**
+ * @testdox show_shipping() keeps the totals it found when a guarded field filter recalculates them for an address without a city.
+ */
+ public function test_show_shipping_keeps_the_totals_when_a_guarded_field_filter_recalculates_them(): void {
+ $this->add_product_for_an_address_without_postcode();
+ WC()->cart->get_customer()->set_shipping_postcode( '10001' );
+ WC()->cart->get_customer()->set_shipping_city( '' );
+ add_filter(
+ 'woocommerce_default_address_fields',
+ function ( $fields ) {
+ static $calculating = false;
+ // Recalculate once per nesting chain, as extensions that read the cart total from a field filter do.
+ if ( ! $calculating ) {
+ $calculating = true;
+ WC()->cart->calculate_totals();
+ $calculating = false;
+ }
+ return $fields;
+ }
+ );
+ WC()->cart->calculate_totals();
+ $shipping_total = (float) WC()->cart->get_shipping_total();
+
+ $result = WC()->cart->show_shipping();
+
+ $this->assertGreaterThan( 0.0, $shipping_total, 'The classic check does not require a city, so the flat rate should be charged.' );
+ $this->assertTrue( $result, 'The classic check does not require a city.' );
+ $this->assertSame( $shipping_total, (float) WC()->cart->get_shipping_total(), 'The recalculation inside show_shipping() should keep the shipping total.' );
+ $this->assertSame( (float) WC()->cart->get_subtotal() + $shipping_total, (float) WC()->cart->get_total( 'edit' ), 'The total should still include shipping.' );
+ }
+
+ /**
+ * @testdox show_shipping() leaves totals that match its answer when a field filter recalculates them and makes the postcode optional.
+ */
+ public function test_show_shipping_leaves_totals_that_match_its_answer_when_a_field_filter_recalculates_them(): void {
+ $this->add_product_for_an_address_without_postcode();
+ $calls = 0;
+ add_filter(
+ 'woocommerce_shipping_fields',
+ function ( $fields ) use ( &$calls ) {
+ ++$calls;
+ $fields['shipping_postcode']['required'] = false;
+ // Stop a runaway recursion so a regression fails an assertion below instead of exhausting the process.
+ if ( $calls <= 30 ) {
+ WC()->cart->calculate_totals();
+ }
+ return $fields;
+ }
+ );
+ WC()->cart->calculate_totals();
+
+ $result = WC()->cart->show_shipping();
+
+ $this->assertTrue( $result, 'The shipping fields filter makes the postcode optional.' );
+ $this->assertTrue( WC()->cart->has_calculated_shipping(), 'The recalculation inside show_shipping() should keep shipping calculated.' );
+ $this->assertGreaterThan( 0.0, (float) WC()->cart->get_shipping_total(), 'The recalculation inside show_shipping() should keep the flat rate.' );
+ $this->assertSame( (float) WC()->cart->get_subtotal() + (float) WC()->cart->get_shipping_total(), (float) WC()->cart->get_total( 'edit' ), 'The total should include shipping.' );
+ }
+
+ /**
+ * @testdox show_shipping() reads the address fields again after a call that ran a nested check.
+ */
+ public function test_show_shipping_reads_the_address_fields_again_after_a_nested_check(): void {
+ $this->add_product_for_an_address_without_postcode();
+ $ran_nested_check = false;
+ add_filter(
+ 'woocommerce_shipping_fields',
+ function ( $fields ) use ( &$ran_nested_check ) {
+ if ( ! $ran_nested_check ) {
+ $ran_nested_check = true;
+ WC()->cart->show_shipping();
+ }
+ return $fields;
+ }
+ );
+ WC()->cart->show_shipping();
+ add_filter(
+ 'woocommerce_shipping_fields',
+ function ( $fields ) {
+ $fields['shipping_postcode']['required'] = false;
+ return $fields;
+ }
+ );
+
+ $result = WC()->cart->show_shipping();
+
+ $this->assertTrue( $result, 'A later call should apply a shipping fields filter that makes the postcode optional.' );
+ }
+
+ /**
+ * @testdox calculate_totals() leaves out shipping that a nested totals calculation added while the Store API checked shipping.
+ */
+ public function test_calculate_totals_leaves_out_shipping_added_by_a_nested_calculation(): void {
+ $this->add_product_for_an_address_without_postcode();
+ WC()->cart->cart_context = 'store-api';
+ $calls = 0;
+ add_filter(
+ 'woocommerce_default_address_fields',
+ function ( $fields ) use ( &$calls ) {
+ ++$calls;
+ // Stop a runaway recursion so a regression fails an assertion below instead of exhausting the process.
+ if ( $calls <= 10 ) {
+ WC()->cart->calculate_totals();
+ }
+ return $fields;
+ }
+ );
+ // Build the country locale during the calculation, as the first shipping check of a request does.
+ WC()->countries->locale = array();
+
+ WC()->cart->calculate_totals();
+
+ $this->assertFalse( WC()->cart->has_calculated_shipping(), 'A missing postcode should keep shipping uncalculated.' );
+ $this->assertSame( 0.0, (float) WC()->cart->get_shipping_total(), 'A missing postcode should leave the shipping total at zero.' );
+ $this->assertSame( (float) WC()->cart->get_subtotal(), (float) WC()->cart->get_total( 'edit' ), 'The total should not include shipping.' );
+ }
+
+ /**
+ * @testdox calculate_shipping() clears shipping totals before reading address fields and after a field filter changes them.
+ */
+ public function test_calculate_shipping_clears_totals_around_the_address_field_filters(): void {
+ $this->add_product_for_an_address_without_postcode();
+ WC()->cart->set_shipping_total( 9 );
+ WC()->cart->set_shipping_tax( 2 );
+ WC()->cart->set_shipping_taxes( array( 1 => 2 ) );
+ $before_filter = null;
+ add_filter(
+ 'woocommerce_shipping_fields',
+ function ( $fields ) use ( &$before_filter ) {
+ if ( null === $before_filter ) {
+ $before_filter = array( (float) WC()->cart->get_shipping_total(), (float) WC()->cart->get_shipping_tax(), WC()->cart->get_shipping_taxes() );
+ }
+ // Represent shipping state left by an extension's totals calculation inside the field filter.
+ WC()->cart->set_shipping_total( 5 );
+ WC()->cart->set_shipping_tax( 1 );
+ WC()->cart->set_shipping_taxes( array( 1 => 1 ) );
+ return $fields;
+ }
+ );
+
+ $result = WC()->cart->calculate_shipping();
+
+ $this->assertSame( array( 0.0, 0.0, array() ), $before_filter, 'Address field filters should start with cleared shipping totals and taxes.' );
+ $this->assertSame( array(), $result, 'A missing postcode should leave no calculated shipping methods.' );
+ $this->assertSame( 0.0, (float) WC()->cart->get_shipping_total(), 'Rejected shipping should clear the total left by the filter.' );
+ $this->assertSame( 0.0, (float) WC()->cart->get_shipping_tax(), 'Rejected shipping should clear the tax left by the filter.' );
+ $this->assertSame( array(), WC()->cart->get_shipping_taxes(), 'Rejected shipping should clear the tax breakdown left by the filter.' );
+ $this->assertFalse( WC()->cart->has_calculated_shipping(), 'A missing postcode should keep shipping uncalculated.' );
+ }
+
+ /**
+ * @testdox calculate_totals() leaves out shipping that a nested check allowed while the address fields still require a postcode.
+ */
+ public function test_calculate_totals_leaves_out_shipping_that_a_nested_check_allowed(): void {
+ $this->add_product_for_an_address_without_postcode();
+ // The AE locale makes the postcode optional, so the innermost nested check, which reads the locale, allows shipping.
+ WC()->cart->get_customer()->set_shipping_country( 'AE' );
+ WC()->cart->get_customer()->set_shipping_state( '' );
+ WC()->cart->get_customer()->set_shipping_city( 'Dubai' );
+ $calls = 0;
+ add_filter(
+ 'woocommerce_shipping_fields',
+ function ( $fields ) use ( &$calls ) {
+ ++$calls;
+ $fields['shipping_postcode']['required'] = true;
+ // The AE locale also hides the postcode, and show_shipping() skips a hidden field, so show it.
+ $fields['shipping_postcode']['hidden'] = false;
+ // Stop a runaway recursion so a regression fails an assertion below instead of exhausting the process.
+ if ( $calls <= 30 ) {
+ WC()->cart->calculate_totals();
+ }
+ return $fields;
+ }
+ );
+
+ WC()->cart->calculate_totals();
+
+ $this->assertFalse( WC()->cart->has_calculated_shipping(), 'A postcode that the shipping fields require should keep shipping uncalculated.' );
+ $this->assertSame( 0.0, (float) WC()->cart->get_shipping_total(), 'A postcode that the shipping fields require should leave the shipping total at zero.' );
+ }
+
+ /**
+ * @testdox calculate_totals() charges shipping when the shipping fields make the postcode optional, even though a nested check requires it.
+ */
+ public function test_calculate_totals_charges_shipping_when_the_fields_make_the_postcode_optional(): void {
+ $this->add_product_for_an_address_without_postcode();
+ $calls = 0;
+ add_filter(
+ 'woocommerce_shipping_fields',
+ function ( $fields ) use ( &$calls ) {
+ ++$calls;
+ $fields['shipping_postcode']['required'] = false;
+ // Stop a runaway recursion so a regression fails an assertion below instead of exhausting the process.
+ if ( $calls <= 30 ) {
+ WC()->cart->calculate_totals();
+ }
+ return $fields;
+ }
+ );
+
+ WC()->cart->calculate_totals();
+
+ $this->assertTrue( WC()->cart->has_calculated_shipping(), 'An optional postcode should let shipping be calculated.' );
+ $this->assertGreaterThan( 0.0, (float) WC()->cart->get_shipping_total(), 'An optional postcode should charge the flat rate.' );
+ }
+
+ /**
+ * Add a product to the cart and give the customer a US shipping address without a postcode, with shipping costs hidden until an address is entered.
+ */
+ private function add_product_for_an_address_without_postcode(): void {
+ $this->add_product_for_a_us_address_missing( 'postcode' );
+ $this->clear_checkout_fields();
+ }
+
+ /**
+ * Clear the checkout fields that WC_Checkout keeps after first building them, so the next read builds them through the filters again.
+ */
+ private function clear_checkout_fields(): void {
+ $fields = new ReflectionProperty( WC_Checkout::class, 'fields' );
+ $fields->setAccessible( true );
+ $fields->setValue( WC()->checkout(), null );
+ }
+
/**
* @testdox show_shipping() in the classic cart does not wait for an address field that the country locale hides.
*