Commit 3d459701722 for woocommerce

commit 3d459701722b0e0b14d76d98a59dba484904b3e1
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Wed Jun 10 12:38:33 2026 +0300

    Add a Composer PSR-4 fallback autoloader to prevent in-place-upgrade class-not-found fatals (#65338)

    * fix(asset-data-registry): hook enqueue on admin_enqueue_scripts with idempotency

    This revives PR #65005 verbatim — same author, same diff, same tests.

    Hook enqueue_asset_data() on admin_enqueue_scripts (at PHP_INT_MAX, after
    the wc-settings dependency injection at priority 14) so that the data
    registry runs at the enqueue phase rather than during footer printing.
    This addresses #54657 where shipping method instantiation triggered
    via the registry happened too late for hooks like admin_enqueue_scripts
    that extensions register in WC_Shipping_Method constructors.

    A fallback hook on admin_print_footer_scripts is kept so that any late
    add() calls still get emitted, and enqueue_asset_data() is now idempotent
    (guarded by $asset_data_enqueued) so the payload is never printed twice.

    The same mechanism also defuses an in-place upgrade race surfaced by
    the 10.7→10.8 fatal report tracked in 65337 — when admin_enqueue_scripts
    fires BEFORE update.php's body runs the file swap, settings page classes
    get loaded from the pre-upgrade files and pinned in PHP's class table.
    The post-swap admin_print_footer_scripts hook becomes a no-op due to the
    idempotency flag, preventing any "Class not found" fatal on new src/Enums
    classes added by the upgrade. Validated end-to-end on WP 6.9 + WC 10.7 +
    Jetpack 15.8 + WooPayments 10.8 + Yoast SEO 27.7 via a real wp-admin
    upgrade through /wp-admin/update.php — upgrade completes cleanly, debug
    log empty.

    Closes #54657
    Refs #65337

    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * revert: drop the AssetDataRegistry enqueue-timing approach

    The admin_enqueue_scripts/PHP_INT_MAX + idempotency-flag change did not fix
    the target issue and introduced a silent late-add() data-loss regression
    (empirically validated). Replaced by a Composer PSR-4 fallback autoloader.

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * feat(autoloader): add WooCommerce-scoped Composer PSR-4 fallback builder

    Refs #65338

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * fix(autoloader): register PSR-4 fallback to survive in-place-upgrade class misses

    When WordPress swaps WooCommerce's files mid-request during an in-place
    upgrade, the Jetpack autoloader's in-memory classmap snapshot cannot resolve
    classes that are new in the upgraded version, fataling the request. Register a
    WooCommerce-scoped Composer PSR-4 ClassLoader as an appended fallback so those
    classes resolve from disk instead.

    Refs #65338

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * chore: add changelog entry for the PSR-4 upgrade fallback

    Refs #65338

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * style: address final-review nits on the PSR-4 fallback

    Add @since 11.0.0 to build_woocommerce_psr4_fallback() and rename the
    test's system-under-test variable to $sut per the unit-test convention.

    Refs #65338

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * fix(autoloader): rebuild the PSR-4 fallback loader on each miss

    WooCommerce registered a single WC-scoped Composer ClassLoader as the appended PSR-4 fallback. Composer's ClassLoader keeps a per-instance negative cache (missingClasses) and short-circuits repeat lookups for a class it has already missed. During a WordPress in-place upgrade the files are swapped mid-request, so if anything probes a future WooCommerce class (e.g. a defensive class_exists) before the swap, that one instance caches the miss and then keeps refusing the same class after the new file is on disk — for the rest of the request. That is the exact "class not found" fatal the fallback exists to prevent.

    Register a closure that builds a fresh ClassLoader per WooCommerce-namespace miss instead. Each resolution starts with an empty negative cache, so a pre-swap miss no longer defeats a post-swap hit, while Composer's PSR-4 resolution and the Automattic\WooCommerce* scoping are preserved. The appended fallback only fires once every other autoloader (Jetpack included) has missed a WooCommerce class, so the per-miss rebuild cost is negligible.

    The bootstrap drops to a single register_woocommerce_psr4_fallback() call (no instance register()/unset dance), and the tests are reworked to cover the per-miss loader and the appended SPL handler.

    * fix(autoloader): scope the PSR-4 fallback to first-party src classes

    The upgrade fallback was scoped to every Automattic\WooCommerce\* prefix in the Composer map, including the bundled third-party packages under Automattic\WooCommerce\Vendor\ (lib/packages). That contradicted its own docblock and let the fallback load WooCommerce's bundled copy of a vendor library over the version the Jetpack autoloader coordinates across plugins — turning a clean fatal into a subtle mixed-version state. The in-place-upgrade fatal it targets is always a first-party src/ class, so the extra breadth bought nothing.

    Scope the loader to the single Automattic\WooCommerce\ -> src/ prefix; bundled Vendor\ and the non-runtime prefixes (Blueprint, tests, build tooling) are excluded, and the docblock + PR claim are corrected to match.

    Move the Composer ClassLoader require_once inside the try/catch so a torn or partially-written ClassLoader.php mid-upgrade degrades to a null fallback instead of fataling the bootstrap. Make registration idempotent (a static guard returns the existing handler) so a re-entrant bootstrap, WP-CLI, or a test without teardown never stacks duplicate autoloaders.

    Extract the per-miss resolution into find_scoped_file() and test the actual guarantee: a class missed before its file lands on disk resolves after, in the same request — both at the resolver level (temp PSR-4 dir) and end to end through the registered handler. Housekeeping: a use import for ClassLoader, require_once in the handler, $prototype renamed to $availability_probe, and @internal on the methods that are public only for tests.

    * docs(changelog): scope the upgrade-fallback entry to first-party src classes

    ---------

    Co-authored-by: Ayush Pahwa <ayush.pahwa@automattic.com>
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/65338-composer-psr4-upgrade-fallback b/plugins/woocommerce/changelog/65338-composer-psr4-upgrade-fallback
new file mode 100644
index 00000000000..685df6aae94
--- /dev/null
+++ b/plugins/woocommerce/changelog/65338-composer-psr4-upgrade-fallback
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Register a WooCommerce-scoped Composer PSR-4 fallback autoloader so that future in-place upgrades can resolve newly-added first-party Automattic\WooCommerce classes under src/ that the in-memory Jetpack classmap snapshot would otherwise miss, preventing "class not found" fatals during the upgrade request.
diff --git a/plugins/woocommerce/src/Autoloader.php b/plugins/woocommerce/src/Autoloader.php
index d1314896dd6..4cd906a8791 100644
--- a/plugins/woocommerce/src/Autoloader.php
+++ b/plugins/woocommerce/src/Autoloader.php
@@ -5,6 +5,8 @@

 namespace Automattic\WooCommerce;

+use Composer\Autoload\ClassLoader;
+
 defined( 'ABSPATH' ) || exit;

 /**
@@ -42,6 +44,167 @@ class Autoloader {
 		return $autoloader_result;
 	}

+	/**
+	 * Build a WooCommerce-scoped Composer PSR-4 ClassLoader to use as a fallback
+	 * to the Jetpack autoloader.
+	 *
+	 * The Jetpack autoloader reads its classmap into an in-memory snapshot once
+	 * per request and never refreshes it. During a WordPress in-place upgrade the
+	 * plugin files are swapped mid-request, so a class that is new in the upgraded
+	 * version cannot be found in the snapshot and the request fatals. This loader,
+	 * registered as an appended (lowest-priority) fallback, resolves such classes
+	 * from disk via PSR-4.
+	 *
+	 * Scoped to the first-party `Automattic\WooCommerce\` (src/) namespace only —
+	 * the family that actually fatals during an in-place upgrade (e.g.
+	 * `Enums\DefaultCustomerAddress`). Every other prefix in the Composer map is
+	 * deliberately excluded: bundled third-party packages
+	 * (`Automattic\WooCommerce\Vendor\` → lib/packages) so the fallback can never
+	 * load WooCommerce's bundled copy over the version the Jetpack autoloader
+	 * coordinates across plugins, and the non-runtime prefixes (Blueprint, tests,
+	 * build tooling) which never fatal during a front-end upgrade request.
+	 *
+	 * Returns the configured (but NOT registered) loader so the caller controls
+	 * registration and tests can exercise it without touching the global SPL stack.
+	 *
+	 * @internal Public only so {@see self::register_woocommerce_psr4_fallback()} and
+	 *           the unit tests can build the loader in isolation.
+	 *
+	 * @since 11.0.0
+	 *
+	 * @return ClassLoader|null The loader, or null if the Composer files are
+	 *                          unavailable or a foreign ClassLoader shape is present.
+	 */
+	public static function build_woocommerce_psr4_fallback(): ?ClassLoader {
+		$base     = dirname( __DIR__ );
+		$psr4_map = $base . '/vendor/composer/autoload_psr4.php';
+
+		if ( ! is_readable( $psr4_map ) ) {
+			return null;
+		}
+
+		try {
+			// Reuse an already-loaded ClassLoader (another plugin or wp-cli may have
+			// loaded it from a different path); requiring our copy then would fatal
+			// with "Cannot declare class ... already in use". Kept inside the try so a
+			// torn/partially-written ClassLoader.php during a vendor-bundle upgrade
+			// degrades to a null fallback instead of fataling the bootstrap.
+			if ( ! class_exists( ClassLoader::class, false ) ) {
+				$classloader_file = $base . '/vendor/composer/ClassLoader.php';
+				if ( ! is_readable( $classloader_file ) ) {
+					return null;
+				}
+				require_once $classloader_file;
+			}
+
+			$psr4_entries = require $psr4_map;
+			if ( ! is_array( $psr4_entries ) ) {
+				return null;
+			}
+
+			$loader = new ClassLoader();
+			foreach ( $psr4_entries as $namespace => $paths ) {
+				// First-party src/ only — exclude bundled Vendor\ and non-runtime prefixes.
+				if ( 'Automattic\\WooCommerce\\' === $namespace ) {
+					$loader->setPsr4( $namespace, $paths );
+				}
+			}
+			return $loader;
+		} catch ( \Throwable $e ) {
+			// Foreign/ancient ClassLoader shape, or a torn Composer file — skip the
+			// fallback rather than fatal the bootstrap.
+			return null;
+		}
+	}
+
+	/**
+	 * Register the WooCommerce-scoped PSR-4 fallback as an appended (lowest-priority)
+	 * SPL autoloader, so it is consulted only after every other autoloader — including
+	 * the primary Jetpack autoloader — has missed.
+	 *
+	 * The handler resolves each miss with a throwaway loader (see {@see self::find_scoped_file()})
+	 * rather than a single long-lived `ClassLoader`. Composer's `ClassLoader` records a
+	 * per-instance negative cache (`missingClasses`) on a PSR-4 miss and short-circuits
+	 * subsequent lookups for that class; a shared instance would therefore cache a miss for a
+	 * class probed *before* an in-place upgrade swaps the files, then keep refusing that same
+	 * class *after* the new file is on disk — for the remainder of the request. A fresh loader
+	 * per miss keeps every resolution honest while still reusing Composer's PSR-4 resolution.
+	 *
+	 * Registration is idempotent: at most one handler is ever added per request.
+	 *
+	 * @since 11.0.0
+	 *
+	 * @return \Closure|null The registered autoloader, or null if the Composer files are
+	 *                       unavailable (nothing was registered).
+	 */
+	public static function register_woocommerce_psr4_fallback(): ?\Closure {
+		static $registered_handler = null;
+
+		// Idempotent: a re-entrant bootstrap, WP-CLI, or a test without teardown must not
+		// stack duplicate handlers (each one re-builds a loader + stats the FS on every miss).
+		if ( null !== $registered_handler ) {
+			return $registered_handler;
+		}
+
+		// Build once ONLY to validate availability and snapshot the scoped PSR-4 map. The handler
+		// rebuilds a throwaway loader per miss from this captured map (for performance — the map
+		// is read once, not on every miss). Do NOT collapse this into a shared loader or a per-miss
+		// build() call: either reintroduces the negative-cache bug the fresh-per-miss design avoids.
+		$availability_probe = self::build_woocommerce_psr4_fallback();
+		if ( null === $availability_probe ) {
+			return null;
+		}
+		$psr4_entries = $availability_probe->getPrefixesPsr4();
+
+		$handler = static function ( string $class_name ) use ( $psr4_entries ) {
+			$file = self::find_scoped_file( $class_name, $psr4_entries );
+			if ( null !== $file ) {
+				require_once $file;
+			}
+		};
+
+		spl_autoload_register( $handler, true, false );
+		$registered_handler = $handler;
+
+		return $handler;
+	}
+
+	/**
+	 * Resolve a WooCommerce `src/` class to a file via a throwaway PSR-4 `ClassLoader`.
+	 *
+	 * A new loader per call is deliberate (and is the property the fallback exists for): Composer's
+	 * `ClassLoader` keeps a per-instance negative cache, so a single shared instance that missed a
+	 * class *before* an in-place upgrade swapped the files would keep refusing it *after* the new
+	 * file is on disk. Building fresh here guarantees a class missed pre-swap resolves post-swap,
+	 * within the same request.
+	 *
+	 * @internal Public only so the registered autoload handler and the unit tests can drive it.
+	 *
+	 * @param string                      $class_name   Fully-qualified class name.
+	 * @param array<string, list<string>> $psr4_entries Pre-scoped PSR-4 prefix => dirs map.
+	 *
+	 * @return string|null Absolute file path to require, or null on a miss or a
+	 *                     non-`Automattic\WooCommerce\` class.
+	 */
+	public static function find_scoped_file( string $class_name, array $psr4_entries ): ?string {
+		if ( 0 !== strpos( $class_name, 'Automattic\\WooCommerce\\' ) ) {
+			return null;
+		}
+
+		try {
+			$loader = new ClassLoader();
+			foreach ( $psr4_entries as $namespace => $paths ) {
+				$loader->setPsr4( $namespace, $paths );
+			}
+			$file = $loader->findFile( $class_name );
+		} catch ( \Throwable $e ) {
+			// Foreign/malformed ClassLoader — miss rather than fatal the autoload path.
+			return null;
+		}
+
+		return false !== $file ? $file : null;
+	}
+
 	/**
 	 * If the autoloader is missing, add an admin notice.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/AutoloaderTest.php b/plugins/woocommerce/tests/php/src/AutoloaderTest.php
new file mode 100644
index 00000000000..3597a33aed4
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/AutoloaderTest.php
@@ -0,0 +1,205 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests;
+
+use Automattic\WooCommerce\Autoloader;
+use Composer\Autoload\ClassLoader;
+
+/**
+ * Tests for the WooCommerce-scoped Composer PSR-4 fallback autoloader.
+ *
+ * @package Automattic\WooCommerce\Tests
+ */
+class AutoloaderTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * The builder returns a ClassLoader scoped to the first-party `src/` namespace
+	 * only: it resolves a real src class, and refuses the bundled `Vendor\` packages,
+	 * non-WooCommerce vendor namespaces, and non-existent classes.
+	 *
+	 * @testdox build_woocommerce_psr4_fallback() resolves src classes only.
+	 */
+	public function test_build_woocommerce_psr4_fallback_scopes_to_src(): void {
+		$sut = Autoloader::build_woocommerce_psr4_fallback();
+
+		$this->assertInstanceOf(
+			ClassLoader::class,
+			$sut,
+			'Builder must return a ClassLoader when the Composer files are present (they ship in the build).'
+		);
+
+		// Positive: resolves a real WooCommerce src class from disk via PSR-4.
+		$this->assertNotFalse(
+			$sut->findFile( 'Automattic\\WooCommerce\\Enums\\DefaultCustomerAddress' ),
+			'Fallback must resolve a WooCommerce src class.'
+		);
+
+		// Excluded: bundled third-party under Vendor\ (lib/packages) must NOT resolve, so the
+		// fallback can never load WooCommerce's bundled copy over the Jetpack-coordinated version.
+		$this->assertFalse(
+			$sut->findFile( 'Automattic\\WooCommerce\\Vendor\\Psr\\Container\\ContainerInterface' ),
+			'Fallback must exclude bundled Vendor\\ packages.'
+		);
+
+		// Excluded: a non-WooCommerce vendor namespace that exists in the full map.
+		$this->assertFalse(
+			$sut->findFile( 'Opis\\JsonSchema\\Validator' ),
+			'Fallback must be scoped to WooCommerce src and refuse non-WooCommerce namespaces.'
+		);
+
+		// Bogus: must not invent files for non-existent classes.
+		$this->assertFalse(
+			$sut->findFile( 'Automattic\\WooCommerce\\Nope\\Does_Not_Exist_XYZ' ),
+			'Fallback must not resolve non-existent classes.'
+		);
+	}
+
+	/**
+	 * Each builder call returns a distinct ClassLoader, so Composer's per-instance
+	 * negative cache (missingClasses) is never shared across resolutions.
+	 *
+	 * @testdox build_woocommerce_psr4_fallback() returns a fresh loader each call.
+	 */
+	public function test_build_woocommerce_psr4_fallback_is_not_shared(): void {
+		$first  = Autoloader::build_woocommerce_psr4_fallback();
+		$second = Autoloader::build_woocommerce_psr4_fallback();
+
+		$this->assertInstanceOf( ClassLoader::class, $first );
+		$this->assertInstanceOf( ClassLoader::class, $second );
+		$this->assertNotSame(
+			$first,
+			$second,
+			'Each call must return a distinct loader so the negative cache is never shared across resolutions.'
+		);
+	}
+
+	/**
+	 * The core guarantee: a class missed *before* its file lands on disk resolves
+	 * *after*, within the same request — because each call resolves with a throwaway
+	 * loader that carries no negative cache from the earlier miss.
+	 *
+	 * @testdox find_scoped_file() resolves a class once its file appears mid-request.
+	 */
+	public function test_find_scoped_file_resolves_after_the_file_appears(): void {
+		$base  = sys_get_temp_dir() . '/wc_autoloader_' . str_replace( '.', '', uniqid( '', true ) );
+		$file  = $base . '/Widget.php';
+		$class = 'Automattic\\WooCommerce\\ReproNs\\Widget';
+		$map   = array( 'Automattic\\WooCommerce\\ReproNs\\' => array( $base ) );
+
+		try {
+			wp_mkdir_p( $base );
+
+			// Miss: the class file does not exist yet.
+			$this->assertNull(
+				Autoloader::find_scoped_file( $class, $map ),
+				'Must miss while the class file is absent.'
+			);
+
+			// The file appears mid-request (as a WordPress in-place upgrade would swap it in).
+			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture; WP_Filesystem adds no value here.
+			file_put_contents( $file, "<?php\nnamespace Automattic\\WooCommerce\\ReproNs;\nclass Widget {}\n" );
+			clearstatcache( true, $file );
+
+			// Resolve: a fresh loader (no carried-over negative cache) finds the new file.
+			$resolved = Autoloader::find_scoped_file( $class, $map );
+			$this->assertNotNull( $resolved, 'Must resolve once the file is on disk.' );
+			$this->assertSame(
+				realpath( $file ),
+				realpath( (string) $resolved ),
+				'Must resolve to the file that appeared on disk.'
+			);
+		} finally {
+			if ( file_exists( $file ) ) {
+				wp_delete_file( $file );
+			}
+			if ( is_dir( $base ) ) {
+				rmdir( $base ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Test fixture cleanup.
+			}
+		}
+	}
+
+	/**
+	 * The resolver ignores classes outside the `Automattic\WooCommerce\` namespace.
+	 *
+	 * @testdox find_scoped_file() ignores non-WooCommerce classes.
+	 */
+	public function test_find_scoped_file_ignores_non_woocommerce_classes(): void {
+		$map = array( 'Automattic\\WooCommerce\\' => array( dirname( WC_PLUGIN_FILE ) . '/src' ) );
+
+		$this->assertNull(
+			Autoloader::find_scoped_file( 'Opis\\JsonSchema\\Validator', $map ),
+			'Must ignore classes outside the Automattic\\WooCommerce\\ namespace.'
+		);
+	}
+
+	/**
+	 * End-to-end: the autoloader registered by the bootstrap actually `require`s a real
+	 * src class that appears on disk after an earlier miss, in the same request.
+	 *
+	 * @testdox the registered handler requires a src class that appears after a miss.
+	 */
+	public function test_registered_handler_requires_an_appearing_src_class(): void {
+		$handler = Autoloader::register_woocommerce_psr4_fallback();
+		$this->assertInstanceOf( \Closure::class, $handler, 'Bootstrap must register a handler.' );
+
+		$suffix = 'ReproFixture' . str_replace( '.', '', uniqid( '', true ) );
+		$dir    = dirname( WC_PLUGIN_FILE ) . '/src/' . $suffix;
+		$file   = $dir . '/Widget.php';
+		$class  = 'Automattic\\WooCommerce\\' . $suffix . '\\Widget';
+
+		$this->assertFalse( class_exists( $class, false ), 'Precondition: fixture class must not be loaded.' );
+
+		try {
+			// File absent: the handler is a no-op (miss), never a fatal.
+			$handler( $class );
+			$this->assertFalse(
+				class_exists( $class, false ),
+				'Handler must not load a class whose file is absent.'
+			);
+
+			// File appears mid-request: the handler resolves it from disk and requires it.
+			wp_mkdir_p( $dir );
+			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture; WP_Filesystem adds no value here.
+			file_put_contents( $file, "<?php\nnamespace Automattic\\WooCommerce\\{$suffix};\nclass Widget {}\n" );
+			clearstatcache( true, $file );
+
+			$handler( $class );
+			$this->assertTrue(
+				class_exists( $class, false ),
+				'Handler must require a src class that appeared on disk after an earlier miss.'
+			);
+		} finally {
+			if ( file_exists( $file ) ) {
+				wp_delete_file( $file );
+			}
+			if ( is_dir( $dir ) ) {
+				rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Test fixture cleanup.
+			}
+		}
+	}
+
+	/**
+	 * Registration is idempotent: repeated calls return the same handler and never
+	 * stack duplicate autoloaders on the SPL stack.
+	 *
+	 * @testdox register_woocommerce_psr4_fallback() is idempotent.
+	 */
+	public function test_register_woocommerce_psr4_fallback_is_idempotent(): void {
+		$first       = Autoloader::register_woocommerce_psr4_fallback();
+		$stack_after = spl_autoload_functions();
+		$second      = Autoloader::register_woocommerce_psr4_fallback();
+
+		$this->assertInstanceOf( \Closure::class, $first );
+		$this->assertSame( $first, $second, 'Repeat registration must return the same handler.' );
+		$this->assertSame(
+			$stack_after,
+			spl_autoload_functions(),
+			'Repeat registration must not add a duplicate handler to the SPL stack.'
+		);
+		$this->assertTrue(
+			in_array( $first, spl_autoload_functions(), true ),
+			'The registered handler must be present on the SPL stack.'
+		);
+	}
+}
diff --git a/plugins/woocommerce/woocommerce.php b/plugins/woocommerce/woocommerce.php
index ee35dde1684..83f82adc907 100644
--- a/plugins/woocommerce/woocommerce.php
+++ b/plugins/woocommerce/woocommerce.php
@@ -29,6 +29,14 @@ if ( ! \Automattic\WooCommerce\Autoloader::init() ) {
 }
 \Automattic\WooCommerce\Packages::init();

+// Register a WooCommerce-scoped Composer PSR-4 autoloader on the SPL stack as a
+// low-priority (appended) fallback, consulted only after every other autoloader
+// — including the primary Jetpack autoloader — has missed. When a WordPress
+// in-place upgrade swaps WooCommerce's files mid-request, the Jetpack classmap
+// snapshot (captured at request start, never refreshed) cannot resolve a class
+// that is new in the upgraded version; this fallback resolves it from disk.
+\Automattic\WooCommerce\Autoloader::register_woocommerce_psr4_fallback();
+
 // Include the main WooCommerce class.
 if ( ! class_exists( 'WooCommerce', false ) ) {
 	include_once dirname( WC_PLUGIN_FILE ) . '/includes/class-woocommerce.php';