Commit de89d235014 for woocommerce
commit de89d235014baa214f5dd972367cb228870f7335
Author: Francesco <frosso@users.noreply.github.com>
Date: Tue Nov 3 18:16:01 2026 +0100
fix: prefill classic checkout shipping fields from the saved address (#68157)
diff --git a/plugins/woocommerce/changelog/30258-fix-shipping-address-prefill b/plugins/woocommerce/changelog/30258-fix-shipping-address-prefill
new file mode 100644
index 00000000000..9a827fffcde
--- /dev/null
+++ b/plugins/woocommerce/changelog/30258-fix-shipping-address-prefill
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prefill classic checkout shipping fields with the customer's saved shipping address, not their billing address.
diff --git a/plugins/woocommerce/includes/class-wc-checkout.php b/plugins/woocommerce/includes/class-wc-checkout.php
index 64c9c291eb0..261a38dcc6b 100644
--- a/plugins/woocommerce/includes/class-wc-checkout.php
+++ b/plugins/woocommerce/includes/class-wc-checkout.php
@@ -44,6 +44,14 @@ class WC_Checkout {
*/
private const ORDER_ALREADY_PLACED_ERROR = 'checkout-order-already-placed';
+ /**
+ * Shipping address fields that checkout.js fills with the billing address while "Ship to a different address?" is
+ * unticked. WC_AJAX::update_order_review() stores them on the session customer.
+ *
+ * @var string[]
+ */
+ private const SHIPPING_FIELDS_COPIED_FROM_BILLING = array( 'country', 'state', 'postcode', 'city', 'address_1', 'address_2' );
+
/**
* The single instance of the class.
*
@@ -66,12 +74,23 @@ class WC_Checkout {
protected $legacy_posted_data = array();
/**
- * Caches customer object. @see get_value.
+ * Caches customer object.
+ *
+ * @see WC_Checkout::get_value()
*
* @var WC_Customer
*/
private $logged_in_customer = null;
+ /**
+ * Caches the logged-in customer as saved to their account, without session changes.
+ *
+ * @see WC_Checkout::get_saved_customer_for_shipping_field()
+ *
+ * @var WC_Customer|null
+ */
+ private $saved_customer = null;
+
/**
* Order item types that set_data_from_cart() creates from the cart.
*
@@ -1900,19 +1919,15 @@ class WC_Checkout {
return $value;
}
- /**
- * For logged in customers, pull data from their account rather than the session which may contain incomplete data.
- * Another reason is that WC sets shipping address to the billing address on the checkout updates unless the
- * "ship to another address" box is checked. @see issue #20975.
- */
$customer_object = false;
if ( is_user_logged_in() ) {
// Load customer object, but keep it cached to avoid reloading it multiple times.
if ( is_null( $this->logged_in_customer ) ) {
+ // Read through the session, so changes made earlier in the session and defaults such as the billing email prefill the form.
$this->logged_in_customer = new WC_Customer( get_current_user_id(), true );
}
- $customer_object = $this->logged_in_customer;
+ $customer_object = $this->get_saved_customer_for_shipping_field( $input ) ?? $this->logged_in_customer;
}
if ( ! $customer_object ) {
@@ -1931,4 +1946,33 @@ class WC_Checkout {
return apply_filters( 'default_checkout_' . $input, $value, $input );
}
+
+ /**
+ * Get the saved customer to prefill a shipping field from, when the session's shipping address is the billing copy.
+ *
+ * Prefilling from that copy would hide the shipping address saved to the account. Once the copy is detected, every
+ * shipping field comes from the saved record, so the name and address shown belong together. A session address that
+ * differs from billing still wins; one the customer typed to match billing looks the same as the copy.
+ *
+ * @param string $input Name of the input we want to grab data for, e.g. shipping_city.
+ * @return WC_Customer|null The customer as saved to their account, or null to read the session.
+ */
+ private function get_saved_customer_for_shipping_field( $input ): ?WC_Customer {
+ if ( 0 !== strpos( $input, 'shipping_' ) ) {
+ return null;
+ }
+
+ foreach ( self::SHIPPING_FIELDS_COPIED_FROM_BILLING as $field ) {
+ if ( $this->logged_in_customer->{"get_shipping_$field"}( 'edit' ) !== $this->logged_in_customer->{"get_billing_$field"}( 'edit' ) ) {
+ return null;
+ }
+ }
+
+ if ( is_null( $this->saved_customer ) ) {
+ $this->saved_customer = new WC_Customer( get_current_user_id() );
+ }
+
+ // A record without a country is not an address the customer can ship to, e.g. one holding only a name.
+ return '' !== $this->saved_customer->get_shipping_country( 'edit' ) ? $this->saved_customer : null;
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
index 3c31cd1921a..8c4b266a18a 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
@@ -24,6 +24,24 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
'billing_email' => 'customer@example.com',
);
+ /**
+ * Shipping fields the get_value tests copy from billing and read back, without the 'shipping_' prefix.
+ */
+ private const PREFILLED_SHIPPING_FIELDS = array( 'first_name', 'country', 'state', 'postcode', 'city', 'address_1', 'address_2' );
+
+ /**
+ * A shipping address in New York for Jane, saved to the account.
+ */
+ private const SAVED_SHIPPING_ADDRESS = array(
+ 'shipping_first_name' => 'Jane',
+ 'shipping_country' => 'US',
+ 'shipping_state' => 'NY',
+ 'shipping_postcode' => '11201',
+ 'shipping_city' => 'Brooklyn',
+ 'shipping_address_1' => '2 Shipping Avenue',
+ 'shipping_address_2' => 'Apt 2',
+ );
+
/**
* @var object The system under test.
*/
@@ -115,6 +133,10 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
$this->original_session_customer = null;
}
+ wp_set_current_user( 0 );
+ WC()->session->set( 'customer', null );
+ WC()->customer = new WC_Customer( 0, true );
+
$customer = WC()->customer;
$customer->set_billing_location( $this->original_customer[0], $this->original_customer[1] );
$customer->set_shipping_location( $this->original_customer[2], $this->original_customer[3] );
@@ -936,6 +958,125 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
WC()->customer = $orig_customer;
}
+ /**
+ * Log in a customer named John with a billing address in California, and make them the session customer.
+ *
+ * @param array $saved_shipping Shipping props to save to the account.
+ */
+ private function log_in_customer( array $saved_shipping ): void {
+ $customer = new WC_Customer( $this->factory->user->create( array( 'role' => 'customer' ) ) );
+ $customer->set_props(
+ array(
+ 'billing_first_name' => 'John',
+ 'billing_country' => 'US',
+ 'billing_state' => 'CA',
+ 'billing_postcode' => '94103',
+ 'billing_city' => 'San Francisco',
+ 'billing_address_1' => '1 Billing Street',
+ 'billing_address_2' => 'Apt 1',
+ ) + $saved_shipping
+ );
+ $customer->save();
+
+ wp_set_current_user( $customer->get_id() );
+ WC()->customer = new WC_Customer( $customer->get_id(), true );
+ }
+
+ /**
+ * Copy the billing details over the session's shipping ones, as checkout does while "Ship to a different address?"
+ * is unticked: checkout.js posts the billing address as the shipping one on every update, and submitting the form
+ * copies the name too, even when validation then fails.
+ */
+ private function copy_billing_to_shipping_in_session(): void {
+ foreach ( self::PREFILLED_SHIPPING_FIELDS as $field ) {
+ WC()->customer->{"set_shipping_$field"}( WC()->customer->{"get_billing_$field"}( 'edit' ) );
+ }
+
+ WC()->customer->save();
+ }
+
+ /**
+ * Get the values checkout prefills the shipping fields with.
+ *
+ * @return array Values keyed by field name without the 'shipping_' prefix.
+ */
+ private function get_shipping_prefill(): array {
+ $prefill = array();
+
+ foreach ( self::PREFILLED_SHIPPING_FIELDS as $field ) {
+ $prefill[ $field ] = $this->sut->get_value( "shipping_$field" );
+ }
+
+ return $prefill;
+ }
+
+ /**
+ * @testdox 'get_value' prefills the saved shipping address when the session only holds the billing copy.
+ */
+ public function test_get_value_prefills_saved_shipping_address_over_billing_copy(): void {
+ $this->log_in_customer( self::SAVED_SHIPPING_ADDRESS );
+ WC()->customer->set_billing_company( 'Session Company' );
+ $this->copy_billing_to_shipping_in_session();
+
+ $this->assertSame(
+ array(
+ 'first_name' => 'Jane',
+ 'country' => 'US',
+ 'state' => 'NY',
+ 'postcode' => '11201',
+ 'city' => 'Brooklyn',
+ 'address_1' => '2 Shipping Avenue',
+ 'address_2' => 'Apt 2',
+ ),
+ $this->get_shipping_prefill()
+ );
+ $this->assertSame( 'Session Company', $this->sut->get_value( 'billing_company' ), 'Billing fields should still come from the session.' );
+ }
+
+ /**
+ * @testdox 'get_value' keeps a shipping address the customer entered during the session.
+ */
+ public function test_get_value_keeps_shipping_address_entered_in_session(): void {
+ $this->log_in_customer( self::SAVED_SHIPPING_ADDRESS );
+ // What the cart shipping calculator stores for a customer with a billing name. It clears the street lines.
+ WC()->customer->set_shipping_location( 'US', 'WA', '98101', 'Seattle' );
+ WC()->customer->save();
+
+ $this->assertSame(
+ array(
+ 'first_name' => 'Jane',
+ 'country' => 'US',
+ 'state' => 'WA',
+ 'postcode' => '98101',
+ 'city' => 'Seattle',
+ 'address_1' => null,
+ 'address_2' => null,
+ ),
+ $this->get_shipping_prefill()
+ );
+ }
+
+ /**
+ * @testdox 'get_value' keeps the billing copy when the customer has no saved shipping address.
+ */
+ public function test_get_value_keeps_billing_copy_without_saved_shipping_address(): void {
+ $this->log_in_customer( array( 'shipping_first_name' => 'Jane' ) );
+ $this->copy_billing_to_shipping_in_session();
+
+ $this->assertSame(
+ array(
+ 'first_name' => 'John',
+ 'country' => 'US',
+ 'state' => 'CA',
+ 'postcode' => '94103',
+ 'city' => 'San Francisco',
+ 'address_1' => '1 Billing Street',
+ 'address_2' => 'Apt 1',
+ ),
+ $this->get_shipping_prefill()
+ );
+ }
+
/**
* @testdox create_order_tax_lines sets rate_code, label, compound and rate_percent on order tax items.
*/