Commit 92f42edc489 for woocommerce
commit 92f42edc489be34a5586a98e3458faa8bc9f4278
Author: Sam Najian <dev@najian.info>
Date: Thu Oct 15 15:26:07 2026 +0200
Memoize shipping package calculation within a request to avoid repeated woocommerce_shipping_packages runs (#65549)
* Memoize shipping package calculation within a request
WC_Shipping::calculate_shipping() ran the woocommerce_shipping_packages
filter on every call. While per-package rates are cached in the session,
that filter had no cache, and the method is invoked several times per
request (cart totals, fragment refreshes, Store API). Extensions that
hook expensive logic onto woocommerce_shipping_packages therefore re-ran
it repeatedly, compounding into 10-20s delays on add-to-cart and cart
updates.
Hash the incoming packages and skip recalculation (including the filter)
when they are unchanged since the last call within the same request.
reset_shipping() clears the memo so cart changes still force a recalc,
and debug mode bypasses it like the per-package cache.
* Reuse per-package hashes instead of re-hashing the whole package set
The first pass hashed the entire packages array with wp_json_encode + md5,
duplicating the per-package hashing that calculate_shipping_for_package
already does. Collect those per-package hashes from the (cheap,
session-cached) calculation loop and combine them into the memo key, so
the packages are hashed once.
This also narrows the memo: the loop now always runs as before, and only
the expensive woocommerce_shipping_packages filter is skipped on a cache
hit, returning the previously filtered (and possibly reorganized) result.
* Test that shipping debug mode bypasses the packages filter memo
Pins the documented behavior that woocommerce_shipping_debug_mode forces
the woocommerce_shipping_packages filter to run on every call.
* Preserve in-progress shipping package state
* Address review feedback on #65549
- Include mutable product data in the packages filter cache key
* Fix shipping package filter cache keys
* Fix shipping package cache key normalization
diff --git a/plugins/woocommerce/changelog/fix-wooplug-6486-shipping-packages-memoization b/plugins/woocommerce/changelog/fix-wooplug-6486-shipping-packages-memoization
new file mode 100644
index 00000000000..f3876506c63
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-6486-shipping-packages-memoization
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Allow extensions to opt into request-scoped memoization of the woocommerce_shipping_packages filter when every callback is safe to cache.
diff --git a/plugins/woocommerce/includes/class-wc-shipping.php b/plugins/woocommerce/includes/class-wc-shipping.php
index 1c9e335f90e..3f87ed7b57c 100644
--- a/plugins/woocommerce/includes/class-wc-shipping.php
+++ b/plugins/woocommerce/includes/class-wc-shipping.php
@@ -48,6 +48,20 @@ class WC_Shipping {
*/
public $packages = array();
+ /**
+ * Cache key for the last `woocommerce_shipping_packages` filter result.
+ *
+ * @var string|null
+ */
+ private $shipping_packages_filter_cache_key = null;
+
+ /**
+ * Last filtered shipping packages for cache-hit reuse.
+ *
+ * @var array
+ */
+ private $shipping_packages_filter_cache = array();
+
/**
* The single instance of the class
*
@@ -256,7 +270,9 @@ class WC_Shipping {
$this->packages = array();
if ( ! $this->enabled || empty( $packages ) ) {
- return array();
+ $this->shipping_packages_filter_cache_key = null;
+ $this->shipping_packages_filter_cache = array();
+ return $this->packages;
}
// Calculate costs for passed packages.
@@ -264,6 +280,29 @@ class WC_Shipping {
$this->packages[ $package_key ] = $this->calculate_shipping_for_package( $package, $package_key );
}
+ /**
+ * Filters whether the shipping packages filter result may be cached for the current request.
+ *
+ * Enable this only when every callback on `woocommerce_shipping_packages` depends solely on the
+ * calculated packages. The filter continues to run on every call by default.
+ *
+ * @since 11.3.0
+ *
+ * @param bool $enabled Whether to cache the filtered packages. Default false.
+ * @param array $packages The calculated packages before filtering.
+ */
+ $cache_enabled = 'yes' !== get_option( 'woocommerce_shipping_debug_mode', 'no' ) && (bool) apply_filters( 'woocommerce_shipping_packages_cache_enabled', false, $this->packages );
+ $cache_key = null;
+
+ if ( $cache_enabled ) {
+ $cache_key = $this->get_shipping_packages_filter_cache_key( $this->packages );
+ }
+
+ if ( null !== $cache_key && $cache_key === $this->shipping_packages_filter_cache_key ) {
+ $this->packages = $this->shipping_packages_filter_cache;
+ return $this->packages;
+ }
+
/**
* Allow packages to be reorganized after calculating the shipping.
*
@@ -277,9 +316,96 @@ class WC_Shipping {
*/
$this->packages = array_filter( (array) apply_filters( 'woocommerce_shipping_packages', $this->packages ) );
+ $this->shipping_packages_filter_cache_key = $cache_key;
+ $this->shipping_packages_filter_cache = $this->packages;
+
return $this->packages;
}
+ /**
+ * Get a cache key that includes mutable data objects visible to package filters.
+ *
+ * @param array $packages Calculated shipping packages.
+ * @return string|null
+ */
+ private function get_shipping_packages_filter_cache_key( array $packages ): ?string {
+ $cacheable = true;
+ $normalized_packages = self::normalize_shipping_packages_filter_cache_value( $packages, $cacheable, new SplObjectStorage() );
+
+ if ( ! $cacheable ) {
+ return null;
+ }
+
+ $encoded_packages = wp_json_encode( $normalized_packages );
+ return false === $encoded_packages ? null : md5( $encoded_packages );
+ }
+
+ /**
+ * Normalize filter-visible values for the shipping packages cache key.
+ *
+ * @param mixed $value Value to normalize.
+ * @param bool $cacheable Whether the value can be represented safely.
+ * @param SplObjectStorage $seen_objects Objects in the current recursion path.
+ * @phpstan-param SplObjectStorage<object, null> $seen_objects
+ * @return mixed
+ */
+ private static function normalize_shipping_packages_filter_cache_value( $value, bool &$cacheable, SplObjectStorage $seen_objects ) {
+ if ( is_array( $value ) ) {
+ foreach ( $value as $key => $nested_value ) {
+ $value[ $key ] = self::normalize_shipping_packages_filter_cache_value( $nested_value, $cacheable, $seen_objects );
+
+ if ( ! $cacheable ) {
+ return null;
+ }
+ }
+
+ return $value;
+ }
+
+ if ( ! is_object( $value ) ) {
+ return $value;
+ }
+
+ if ( $seen_objects->contains( $value ) ) {
+ $cacheable = false;
+ return null;
+ }
+
+ $seen_objects->attach( $value );
+
+ if ( $value instanceof WC_Data ) {
+ $normalized_value = array(
+ 'class' => get_class( $value ),
+ 'data' => array_replace( $value->get_data(), $value->get_changes() ),
+ );
+ } elseif ( $value instanceof WC_Meta_Data ) {
+ $normalized_value = array(
+ 'class' => get_class( $value ),
+ 'data' => array_replace( $value->get_data(), $value->get_changes() ),
+ );
+ } elseif ( $value instanceof WC_DateTime ) {
+ $normalized_value = array(
+ 'class' => get_class( $value ),
+ 'timestamp' => $value->getTimestamp(),
+ 'timezone' => $value->getTimezone()->getName(),
+ 'offset' => $value->getOffset(),
+ );
+ } elseif ( $value instanceof JsonSerializable ) {
+ $normalized_value = array(
+ 'class' => get_class( $value ),
+ 'data' => $value->jsonSerialize(),
+ );
+ } else {
+ $cacheable = false;
+ return null;
+ }
+
+ $normalized_value = self::normalize_shipping_packages_filter_cache_value( $normalized_value, $cacheable, $seen_objects );
+ $seen_objects->detach( $value );
+
+ return $normalized_value;
+ }
+
/**
* See if package is shippable.
*
@@ -466,7 +592,9 @@ class WC_Shipping {
*/
public function reset_shipping() {
unset( WC()->session->chosen_shipping_methods );
- $this->packages = array();
+ $this->packages = array();
+ $this->shipping_packages_filter_cache_key = null;
+ $this->shipping_packages_filter_cache = array();
}
/**
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php b/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php
index 0ef2b133c72..532a7079b00 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php
@@ -359,6 +359,340 @@ class WC_Shipping_Test extends WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox The shipping packages filter runs on every call unless caching is explicitly enabled.
+ */
+ public function test_calculate_shipping_does_not_memoize_packages_filter_by_default(): void {
+ update_option( 'woocommerce_shipping_debug_mode', 'no' );
+
+ $packages = array( $this->get_package_hash_test_package() );
+ $filter_calls = 0;
+ $filter = function ( $shipping_packages ) use ( &$filter_calls ) {
+ ++$filter_calls;
+ return $shipping_packages;
+ };
+
+ add_filter( 'woocommerce_shipping_packages', $filter );
+
+ $this->sut->calculate_shipping( $packages );
+ $this->sut->calculate_shipping( $packages );
+ $this->sut->calculate_shipping( $packages );
+
+ $this->assertSame( 3, $filter_calls, 'The public filter should retain its existing timing by default.' );
+ }
+
+ /**
+ * @testdox Package calculation exposes the packages completed so far through get_packages().
+ */
+ public function test_calculate_shipping_exposes_in_progress_packages(): void {
+ $observed_packages = array();
+ $packages = array(
+ 'first' => array(
+ 'package_id' => 'first',
+ 'rates' => array(),
+ ),
+ 'second' => array(
+ 'package_id' => 'second',
+ 'rates' => array(),
+ ),
+ );
+ $this->sut = $this->getMockBuilder( WC_Shipping::class )
+ ->onlyMethods( array( 'calculate_shipping_for_package' ) )
+ ->getMock();
+ $this->sut->method( 'calculate_shipping_for_package' )
+ ->willReturnCallback(
+ function ( $package ) use ( &$observed_packages ) {
+ $observed_packages[] = $this->sut->get_packages();
+ return $package;
+ }
+ );
+
+ $this->sut->calculate_shipping( $packages );
+
+ $this->assertSame(
+ array(
+ array(),
+ array( 'first' => $packages['first'] ),
+ ),
+ $observed_packages,
+ 'Callbacks should see only packages completed before the current package.'
+ );
+ }
+
+ /**
+ * @testdox The shipping packages filter result is memoized when caching is explicitly enabled.
+ */
+ public function test_calculate_shipping_memoizes_packages_filter_when_enabled(): void {
+ update_option( 'woocommerce_shipping_debug_mode', 'no' );
+
+ $packages = array( $this->get_package_hash_test_package() );
+ $filter_calls = 0;
+ $filter = function ( $shipping_packages ) use ( &$filter_calls ) {
+ ++$filter_calls;
+ $shipping_packages['reorganized'] = true;
+ return $shipping_packages;
+ };
+
+ add_filter( 'woocommerce_shipping_packages_cache_enabled', '__return_true' );
+ add_filter( 'woocommerce_shipping_packages', $filter );
+
+ $first = $this->sut->calculate_shipping( $packages );
+ $second = $this->sut->calculate_shipping( $packages );
+ $third = $this->sut->calculate_shipping( $packages );
+
+ $this->assertSame( 1, $filter_calls, 'The filter should run once for repeated identical calls.' );
+ $this->assertArrayHasKey( 'reorganized', $first, 'The first call should return the filtered packages.' );
+ $this->assertSame( $first, $second, 'The memoized result should preserve the filter output.' );
+ $this->assertSame( $first, $third, 'The memoized result should remain stable.' );
+
+ $packages[0]['destination']['country'] = 'GB';
+ $packages[0]['destination']['state'] = '';
+ $this->sut->calculate_shipping( $packages );
+
+ $this->assertSame( 2, $filter_calls, 'Material package changes should invalidate the memo.' );
+
+ $this->sut->reset_shipping();
+ $this->sut->calculate_shipping( $packages );
+
+ $this->assertSame( 3, $filter_calls, 'Resetting shipping should invalidate the memo.' );
+ }
+
+ /**
+ * @testdox An empty shipping packages filter result is memoized when caching is enabled.
+ */
+ public function test_calculate_shipping_memoizes_empty_packages_filter_result_when_enabled(): void {
+ update_option( 'woocommerce_shipping_debug_mode', 'no' );
+
+ $packages = array( $this->get_package_hash_test_package() );
+ $filter_calls = 0;
+ $filter = function () use ( &$filter_calls ) {
+ ++$filter_calls;
+ return array();
+ };
+
+ add_filter( 'woocommerce_shipping_packages_cache_enabled', '__return_true' );
+ add_filter( 'woocommerce_shipping_packages', $filter );
+
+ $first = $this->sut->calculate_shipping( $packages );
+ $second = $this->sut->calculate_shipping( $packages );
+
+ $this->assertSame( 1, $filter_calls, 'An empty filtered result should still be cached.' );
+ $this->assertSame( array(), $first, 'The first call should return the empty filtered result.' );
+ $this->assertSame( $first, $second, 'The empty result should be returned from the memo.' );
+ }
+
+ /**
+ * @testdox Changing a package key invalidates the shipping packages filter memo.
+ */
+ public function test_calculate_shipping_invalidates_filter_memo_when_package_key_changes(): void {
+ update_option( 'woocommerce_shipping_debug_mode', 'no' );
+
+ $package = $this->get_package_hash_test_package();
+ $filter_calls = 0;
+ $filter = function ( $shipping_packages ) use ( &$filter_calls ) {
+ ++$filter_calls;
+ return $shipping_packages;
+ };
+
+ add_filter( 'woocommerce_shipping_packages_cache_enabled', '__return_true' );
+ add_filter( 'woocommerce_shipping_packages', $filter );
+
+ $this->sut->calculate_shipping( array( 0 => $package ) );
+ $result = $this->sut->calculate_shipping( array( 'replacement' => $package ) );
+
+ $this->assertSame( 2, $filter_calls, 'Changing the package key should invalidate the memo.' );
+ $this->assertArrayHasKey( 'replacement', $result, 'The returned packages should use the current key.' );
+ $this->assertArrayNotHasKey( 0, $result, 'The previous package key should not be returned.' );
+ }
+
+ /**
+ * @testdox Changing non-rate package metadata invalidates the shipping packages filter memo.
+ */
+ public function test_calculate_shipping_invalidates_filter_memo_when_non_rate_metadata_changes(): void {
+ update_option( 'woocommerce_shipping_debug_mode', 'no' );
+
+ $packages = array( $this->get_package_hash_test_package() );
+ $filter_calls = 0;
+ $filter = function ( $shipping_packages ) use ( &$filter_calls ) {
+ ++$filter_calls;
+ return $shipping_packages;
+ };
+
+ add_filter( 'woocommerce_shipping_packages_cache_enabled', '__return_true' );
+ add_filter( 'woocommerce_shipping_packages', $filter );
+
+ $this->sut->calculate_shipping( $packages );
+ $packages[0]['package_name'] = 'Replacement package';
+ $this->sut->calculate_shipping( $packages );
+
+ $this->assertSame( 2, $filter_calls, 'Filter-visible metadata should invalidate the memo.' );
+ }
+
+ /**
+ * @testdox Changing product data invalidates the shipping packages filter memo.
+ */
+ public function test_calculate_shipping_invalidates_filter_memo_when_product_data_changes(): void {
+ update_option( 'woocommerce_shipping_debug_mode', 'no' );
+
+ $packages = array( $this->get_package_hash_test_package() );
+ $product = $packages[0]['contents']['test_item']['data'];
+ $filter_calls = 0;
+ $filter = function ( $shipping_packages ) use ( &$filter_calls ) {
+ ++$filter_calls;
+ return $shipping_packages;
+ };
+
+ $product->set_object_read( true );
+ $product->set_weight( '1' );
+ add_filter( 'woocommerce_shipping_packages_cache_enabled', '__return_true' );
+ add_filter( 'woocommerce_shipping_packages', $filter );
+
+ $this->sut->calculate_shipping( $packages );
+ $product->set_weight( '2' );
+ $this->sut->calculate_shipping( $packages );
+
+ $this->assertSame( 2, $filter_calls, 'Mutable product data should invalidate the memo.' );
+ }
+
+ /**
+ * @testdox Removing nested product data invalidates the shipping packages filter memo.
+ */
+ public function test_calculate_shipping_invalidates_filter_memo_when_nested_product_data_is_removed(): void {
+ update_option( 'woocommerce_shipping_debug_mode', 'no' );
+
+ $packages = array( $this->get_package_hash_test_package() );
+ $product = $packages[0]['contents']['test_item']['data'];
+ $filter_calls = 0;
+ $filter = function ( $shipping_packages ) use ( &$filter_calls ) {
+ ++$filter_calls;
+ return $shipping_packages;
+ };
+
+ $product->set_object_read( true );
+ $product->set_category_ids( array( 123 ) );
+ $product->apply_changes();
+ add_filter( 'woocommerce_shipping_packages_cache_enabled', '__return_true' );
+ add_filter( 'woocommerce_shipping_packages', $filter );
+
+ $this->sut->calculate_shipping( $packages );
+ $product->set_category_ids( array() );
+ $this->sut->calculate_shipping( $packages );
+
+ $this->assertSame( 2, $filter_calls, 'Removing an array-valued product property should invalidate the memo.' );
+ }
+
+ /**
+ * @testdox Product dates are included in the shipping packages filter memo.
+ */
+ public function test_calculate_shipping_memoizes_product_dates(): void {
+ update_option( 'woocommerce_shipping_debug_mode', 'no' );
+
+ $packages = array( $this->get_package_hash_test_package() );
+ $product = $packages[0]['contents']['test_item']['data'];
+ $date_created = new WC_DateTime( '2026-01-01 00:00:00', new DateTimeZone( 'UTC' ) );
+ $filter_calls = 0;
+ $filter = function ( $shipping_packages ) use ( &$filter_calls ) {
+ ++$filter_calls;
+ return $shipping_packages;
+ };
+
+ $product->set_object_read( true );
+ $product->set_date_created( $date_created );
+ $product->apply_changes();
+ add_filter( 'woocommerce_shipping_packages_cache_enabled', '__return_true' );
+ add_filter( 'woocommerce_shipping_packages', $filter );
+
+ $this->sut->calculate_shipping( $packages );
+ $this->sut->calculate_shipping( $packages );
+
+ $this->assertSame( 1, $filter_calls, 'A product date should not disable memoization.' );
+
+ $date_created->modify( '+1 day' );
+ $this->sut->calculate_shipping( $packages );
+
+ $this->assertSame( 2, $filter_calls, 'Changing a product date should invalidate the memo.' );
+ }
+
+ /**
+ * @testdox Pending product metadata changes invalidate the shipping packages filter memo.
+ */
+ public function test_calculate_shipping_invalidates_filter_memo_when_product_metadata_changes(): void {
+ update_option( 'woocommerce_shipping_debug_mode', 'no' );
+
+ $packages = array( $this->get_package_hash_test_package() );
+ $product = $packages[0]['contents']['test_item']['data'];
+ $filter_calls = 0;
+ $filter = function ( $shipping_packages ) use ( &$filter_calls ) {
+ ++$filter_calls;
+ $shipping_packages[0]['observed_metadata'] = $shipping_packages[0]['contents']['test_item']['data']->get_meta( 'shipping_cache_test' );
+ return $shipping_packages;
+ };
+
+ $product->set_object_read( true );
+ $product->add_meta_data( 'shipping_cache_test', 'before', true );
+ $metadata = $product->get_meta_data()[0];
+ add_filter( 'woocommerce_shipping_packages_cache_enabled', '__return_true' );
+ add_filter( 'woocommerce_shipping_packages', $filter );
+
+ $first = $this->sut->calculate_shipping( $packages );
+ $metadata->value = 'after';
+ $second = $this->sut->calculate_shipping( $packages );
+
+ $this->assertSame( 2, $filter_calls, 'Pending metadata changes should invalidate the memo.' );
+ $this->assertSame( 'before', $first[0]['observed_metadata'], 'The first result should use the original metadata.' );
+ $this->assertSame( 'after', $second[0]['observed_metadata'], 'The second result should use the pending metadata.' );
+ }
+
+ /**
+ * @testdox Unsupported objects anywhere in package metadata disable the shipping packages filter memo.
+ */
+ public function test_calculate_shipping_does_not_memoize_unsupported_package_metadata_objects(): void {
+ update_option( 'woocommerce_shipping_debug_mode', 'no' );
+
+ $metadata = new SplObjectStorage();
+ $packages = array( $this->get_package_hash_test_package() );
+ $packages[0]['extension_metadata'] = array( 'state' => $metadata );
+ $filter_calls = 0;
+ $filter = function ( $shipping_packages ) use ( &$filter_calls ) {
+ ++$filter_calls;
+ $shipping_packages[0]['metadata_count'] = count( $shipping_packages[0]['extension_metadata']['state'] );
+ return $shipping_packages;
+ };
+
+ $metadata->attach( new stdClass() );
+ add_filter( 'woocommerce_shipping_packages_cache_enabled', '__return_true' );
+ add_filter( 'woocommerce_shipping_packages', $filter );
+
+ $first = $this->sut->calculate_shipping( $packages );
+ $metadata->attach( new stdClass() );
+ $second = $this->sut->calculate_shipping( $packages );
+
+ $this->assertSame( 2, $filter_calls, 'Unsupported nested objects should disable memoization.' );
+ $this->assertSame( 1, $first[0]['metadata_count'], 'The first filter result should expose the initial object state.' );
+ $this->assertSame( 2, $second[0]['metadata_count'], 'The second filter result should expose the changed object state.' );
+ }
+
+ /**
+ * @testdox Shipping debug mode bypasses the shipping packages filter memo.
+ */
+ public function test_calculate_shipping_does_not_memoize_in_debug_mode(): void {
+ $packages = array( $this->get_package_hash_test_package() );
+ $filter_calls = 0;
+ $filter = function ( $shipping_packages ) use ( &$filter_calls ) {
+ ++$filter_calls;
+ return $shipping_packages;
+ };
+
+ add_filter( 'woocommerce_shipping_packages_cache_enabled', '__return_true' );
+ add_filter( 'woocommerce_shipping_packages', $filter );
+
+ $this->sut->calculate_shipping( $packages );
+ $this->sut->calculate_shipping( $packages );
+ $this->sut->calculate_shipping( $packages );
+
+ $this->assertSame( 3, $filter_calls, 'Debug mode should run the filter on every call.' );
+ }
+
/**
* Data provider for ignored package hash fields.
*