Commit 1b0b15caafe for woocommerce
commit 1b0b15caafe1f1230eae9da74789de66a02bc158
Author: Sakri Koskimies <sakri.koskimies@hotmail.com>
Date: Tue Sep 8 06:48:20 2026 +0300
fix: Preserve custom attachment metadata when regenerating images (#67458)
* fix: Preserve custom attachment metadata when regenerating images
Refs #60577
* test: Remove only the added filter on teardown
* test: Extract the shared image attachment fixtures into a trait
* test: Assert that image regeneration actually ran
* fix: Restore stored metadata when regeneration fails
* test: Assert against the registered thumbnail size
---------
Co-authored-by: Sakri Koskimies <sakri.koskimies@avenla.fi>
diff --git a/plugins/woocommerce/changelog/60577-fix-attachment-metadata-loss b/plugins/woocommerce/changelog/60577-fix-attachment-metadata-loss
new file mode 100644
index 00000000000..7acb401b086
--- /dev/null
+++ b/plugins/woocommerce/changelog/60577-fix-attachment-metadata-loss
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve third party attachment metadata keys when regenerating product images.
diff --git a/plugins/woocommerce/includes/class-wc-regenerate-images-request.php b/plugins/woocommerce/includes/class-wc-regenerate-images-request.php
index c53b784a3c4..c507926339f 100644
--- a/plugins/woocommerce/includes/class-wc-regenerate-images-request.php
+++ b/plugins/woocommerce/includes/class-wc-regenerate-images-request.php
@@ -148,7 +148,8 @@ class WC_Regenerate_Images_Request extends WC_Background_Process {
return false;
}
- $old_metadata = wp_get_attachment_metadata( $this->attachment_id );
+ // Unfiltered, so the keys carried over below come from the stored value.
+ $old_metadata = wp_get_attachment_metadata( $this->attachment_id, true );
// We only want to regen WC images.
add_filter( 'intermediate_image_sizes', array( $this, 'adjust_intermediate_image_sizes' ) );
@@ -165,6 +166,10 @@ class WC_Regenerate_Images_Request extends WC_Background_Process {
// If something went wrong lets just remove the item from the queue.
if ( is_wp_error( $new_metadata ) || empty( $new_metadata ) ) {
+ if ( is_array( $old_metadata ) ) {
+ wp_update_attachment_metadata( $this->attachment_id, $old_metadata );
+ }
+
return false;
}
@@ -176,6 +181,11 @@ class WC_Regenerate_Images_Request extends WC_Background_Process {
}
}
+ // Restore the top level keys regeneration does not own.
+ if ( is_array( $old_metadata ) ) {
+ $new_metadata += $old_metadata;
+ }
+
// Update the meta data with the new size values.
wp_update_attachment_metadata( $this->attachment_id, $new_metadata );
diff --git a/plugins/woocommerce/includes/class-wc-regenerate-images.php b/plugins/woocommerce/includes/class-wc-regenerate-images.php
index fdfd39b39b3..c5f822a6080 100644
--- a/plugins/woocommerce/includes/class-wc-regenerate-images.php
+++ b/plugins/woocommerce/includes/class-wc-regenerate-images.php
@@ -427,7 +427,8 @@ class WC_Regenerate_Images {
}
}
- $metadata = wp_get_attachment_metadata( $attachment_id );
+ // Unfiltered: this is the base for a write, so it must be the stored value.
+ $metadata = wp_get_attachment_metadata( $attachment_id, true );
// Fix for images with no metadata.
if ( ! is_array( $metadata ) ) {
@@ -445,6 +446,8 @@ class WC_Regenerate_Images {
// If something went wrong lets just return the original image.
if ( is_wp_error( $new_metadata ) || empty( $new_metadata ) ) {
+ wp_update_attachment_metadata( $attachment_id, $metadata );
+
return $image;
}
diff --git a/plugins/woocommerce/tests/legacy/bootstrap.php b/plugins/woocommerce/tests/legacy/bootstrap.php
index d78ee26040e..57a8af87ef5 100644
--- a/plugins/woocommerce/tests/legacy/bootstrap.php
+++ b/plugins/woocommerce/tests/legacy/bootstrap.php
@@ -342,6 +342,7 @@ class WC_Unit_Tests_Bootstrap {
require_once dirname( $this->tests_dir ) . '/php/helpers/LoggerSpyTrait.php';
require_once dirname( $this->tests_dir ) . '/php/helpers/MetaDataAssertionTrait.php';
require_once dirname( $this->tests_dir ) . '/php/helpers/CorePayPalGatewayTrait.php';
+ require_once dirname( $this->tests_dir ) . '/php/helpers/ImageAttachmentTrait.php';
}
/**
diff --git a/plugins/woocommerce/tests/php/helpers/ImageAttachmentTrait.php b/plugins/woocommerce/tests/php/helpers/ImageAttachmentTrait.php
new file mode 100644
index 00000000000..99c70024763
--- /dev/null
+++ b/plugins/woocommerce/tests/php/helpers/ImageAttachmentTrait.php
@@ -0,0 +1,113 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Helpers;
+
+/**
+ * Trait ImageAttachmentTrait.
+ *
+ * Image attachment fixtures backed by real files on disk, so tests can exercise code
+ * that reads the image itself rather than just the attachment post.
+ *
+ * The files land in the uploads directory, so call remove_added_uploads() from tearDown().
+ */
+trait ImageAttachmentTrait {
+
+ /**
+ * Create an attachment backed by a real image file.
+ *
+ * @param int $width Width in pixels.
+ * @param int $height Height in pixels.
+ * @param string $filename File name to write into the uploads directory.
+ * @return int Attachment ID.
+ */
+ private function create_image_attachment( int $width, int $height, string $filename ): int {
+ $uploads = wp_upload_dir();
+ $file = trailingslashit( $uploads['path'] ) . $filename;
+
+ wp_mkdir_p( $uploads['path'] );
+
+ $image = imagecreatetruecolor( $width, $height );
+ imagefilledrectangle( $image, 0, 0, $width, $height, imagecolorallocate( $image, 120, 60, 30 ) );
+ imagejpeg( $image, $file, 90 );
+ imagedestroy( $image );
+
+ $attachment_id = wp_insert_attachment(
+ array(
+ 'post_mime_type' => 'image/jpeg',
+ 'post_title' => 'WC image attachment test',
+ 'post_status' => 'inherit',
+ ),
+ $file
+ );
+
+ require_once ABSPATH . 'wp-admin/includes/image.php';
+ wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $file ) );
+
+ return $attachment_id;
+ }
+
+ /**
+ * Add top level metadata keys that WordPress does not own.
+ *
+ * @param int $attachment_id Attachment to add the keys to.
+ * @param array $custom_keys Metadata keyed by name.
+ */
+ private function add_custom_attachment_metadata( int $attachment_id, array $custom_keys ): void {
+ $metadata = wp_get_attachment_metadata( $attachment_id, true );
+
+ wp_update_attachment_metadata( $attachment_id, array_merge( $metadata, $custom_keys ) );
+ }
+
+ /**
+ * Delete a generated size, both the file and its metadata entry.
+ *
+ * @param int $attachment_id Attachment to strip the size from.
+ * @param string $size Size name.
+ * @return string Path of the deleted file.
+ */
+ private function delete_attachment_size( int $attachment_id, string $size ): string {
+ $metadata = wp_get_attachment_metadata( $attachment_id, true );
+ $path = $this->get_attachment_size_path( $attachment_id, $metadata, $size );
+
+ wp_delete_file( $path );
+
+ unset( $metadata['sizes'][ $size ] );
+ wp_update_attachment_metadata( $attachment_id, $metadata );
+
+ return $path;
+ }
+
+ /**
+ * Assert that a size exists as a real image file of the dimensions its metadata records.
+ *
+ * @param int $attachment_id Attachment to check.
+ * @param string $size Size name.
+ */
+ private function assert_size_exists( int $attachment_id, string $size ): void {
+ $metadata = wp_get_attachment_metadata( $attachment_id, true );
+
+ $this->assertArrayHasKey( $size, $metadata['sizes'], "The $size size should be recorded in the metadata" );
+
+ $path = $this->get_attachment_size_path( $attachment_id, $metadata, $size );
+
+ $this->assertFileExists( $path, "The $size file should be on disk" );
+
+ $dimensions = getimagesize( $path );
+
+ $this->assertSame( (int) $metadata['sizes'][ $size ]['width'], $dimensions[0], "The $size file should be as wide as its metadata records" );
+ $this->assertSame( (int) $metadata['sizes'][ $size ]['height'], $dimensions[1], "The $size file should be as tall as its metadata records" );
+ }
+
+ /**
+ * Absolute path of a generated size.
+ *
+ * @param int $attachment_id Attachment the size belongs to.
+ * @param array $metadata Stored attachment metadata.
+ * @param string $size Size name.
+ * @return string
+ */
+ private function get_attachment_size_path( int $attachment_id, array $metadata, string $size ): string {
+ return trailingslashit( dirname( get_attached_file( $attachment_id ) ) ) . $metadata['sizes'][ $size ]['file'];
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-regenerate-images-request-test.php b/plugins/woocommerce/tests/php/includes/class-wc-regenerate-images-request-test.php
new file mode 100644
index 00000000000..d3d707c68f4
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/class-wc-regenerate-images-request-test.php
@@ -0,0 +1,67 @@
+<?php
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Tests\Helpers\ImageAttachmentTrait;
+
+/**
+ * Tests for the WC_Regenerate_Images_Request class.
+ */
+class WC_Regenerate_Images_Request_Test extends WC_Unit_Test_Case {
+
+ use ImageAttachmentTrait;
+
+ /**
+ * The System Under Test.
+ *
+ * @var WC_Regenerate_Images_Request
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new WC_Regenerate_Images_Request();
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ $this->remove_added_uploads();
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Bulk regeneration should keep top level metadata keys it does not own.
+ */
+ public function test_task_preserves_custom_metadata_keys(): void {
+ $attachment_id = $this->create_image_attachment( 1200, 800, 'wc-regen-request-test.jpg' );
+
+ $this->add_custom_attachment_metadata(
+ $attachment_id,
+ array(
+ 'test_api_meta' => array( 'last_modified' => 1708332626 ),
+ 'test_cdn_id' => 'abc123',
+ )
+ );
+
+ $thumbnail = $this->delete_attachment_size( $attachment_id, 'woocommerce_thumbnail' );
+
+ $this->assertFileDoesNotExist( $thumbnail, 'The size should be missing before regeneration runs' );
+
+ $task = new ReflectionMethod( WC_Regenerate_Images_Request::class, 'task' );
+ $task->setAccessible( true );
+ $task->invoke( $this->sut, array( 'attachment_id' => $attachment_id ) );
+
+ $this->assert_size_exists( $attachment_id, 'woocommerce_thumbnail' );
+
+ $stored = get_post_meta( $attachment_id, '_wp_attachment_metadata', true );
+
+ $this->assertArrayHasKey( 'test_api_meta', $stored, 'Third party metadata keys should survive bulk regeneration' );
+ $this->assertArrayHasKey( 'test_cdn_id', $stored, 'Third party metadata keys should survive bulk regeneration' );
+ $this->assertNotEmpty( $stored['sizes'], 'Bulk regeneration should still write the generated sizes' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-regenerate-images-test.php b/plugins/woocommerce/tests/php/includes/class-wc-regenerate-images-test.php
new file mode 100644
index 00000000000..fc5ae4fed04
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/class-wc-regenerate-images-test.php
@@ -0,0 +1,83 @@
+<?php
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Tests\Helpers\ImageAttachmentTrait;
+
+/**
+ * Tests for the WC_Regenerate_Images class.
+ */
+class WC_Regenerate_Images_Test extends WC_Unit_Test_Case {
+
+ use ImageAttachmentTrait;
+
+ /**
+ * Metadata filter callback added by a test, removed on teardown.
+ *
+ * @var callable|null
+ */
+ private $metadata_filter = null;
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ $this->remove_added_uploads();
+
+ if ( $this->metadata_filter ) {
+ remove_filter( 'wp_get_attachment_metadata', $this->metadata_filter );
+ $this->metadata_filter = null;
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Resizing an image on the fly should not persist a filtered view of the metadata.
+ */
+ public function test_resize_does_not_persist_filtered_metadata(): void {
+ // Wide on purpose: maybe_resize_image() only regenerates on an aspect ratio mismatch.
+ $attachment_id = $this->create_image_attachment( 900, 300, 'wc-regen-test.jpg' );
+
+ $this->add_custom_attachment_metadata( $attachment_id, array( 'test_api_meta' => array( 'last_modified' => 1708332626 ) ) );
+
+ // A co-installed plugin hides its own key from readers.
+ $this->metadata_filter = function ( $data ) {
+ if ( is_array( $data ) ) {
+ unset( $data['test_api_meta'] );
+ }
+
+ return $data;
+ };
+ add_filter( 'wp_get_attachment_metadata', $this->metadata_filter );
+
+ $this->assertArrayNotHasKey( 'test_api_meta', wp_get_attachment_metadata( $attachment_id ), 'The filter should hide the key from readers' );
+ $this->assertArrayHasKey( 'test_api_meta', wp_get_attachment_metadata( $attachment_id, true ), 'The stored value should keep the key' );
+
+ $thumbnail = $this->delete_attachment_size( $attachment_id, 'woocommerce_thumbnail' );
+
+ $this->assertFileDoesNotExist( $thumbnail, 'The size should be missing before regeneration runs' );
+
+ // Full size dimensions, which is what image_downsize() returns when the size is missing.
+ $image = array( wp_get_attachment_url( $attachment_id ), 900, 300, false );
+ $returned = WC_Regenerate_Images::maybe_resize_image( $image, $attachment_id, 'woocommerce_thumbnail', false );
+
+ // The registered size, not wc_get_image_size(), is what regeneration produces.
+ $target = wp_get_registered_image_subsizes()['woocommerce_thumbnail'];
+
+ $this->assertSame(
+ array( (int) $target['width'], (int) $target['height'] ),
+ array( $returned[1], $returned[2] ),
+ 'The resized image should be returned, not the original'
+ );
+
+ $this->assert_size_exists( $attachment_id, 'woocommerce_thumbnail' );
+
+ $stored = get_post_meta( $attachment_id, '_wp_attachment_metadata', true );
+
+ $this->assertArrayHasKey(
+ 'test_api_meta',
+ $stored,
+ 'Metadata hidden by a wp_get_attachment_metadata filter should survive on-the-fly regeneration'
+ );
+ }
+}