[WOOTAX-299] Fix - Don't register the continents fallback on unrelated REST requests - #2991
[WOOTAX-299] Fix - Don't register the continents fallback on unrelated REST requests#2991bartech wants to merge 6 commits into
Conversation
…d REST requests `wc_api_dev_init()` decided whether WooCommerce provides `/wc/v3/data/continents` by looking for the route in the current request's route table. That inference was correct when it was written, and stopped being correct in WooCommerce 9.2: core now registers the `wc/v3` namespace lazily via `wc_rest_should_load_namespace()`, which returns false whenever the request targets another known namespace — `wc/store`, `wc-analytics`, `wc-admin` and friends. On every one of those requests the route is legitimately absent even though core supplies the controller, so the bundled `wc-api-dev` fallback fires: two `require_once`s, a controller construction and two `register_rest_route()` calls, registering a route that request can never dispatch to. Nothing observable comes of that — registrations last one request, and a request aimed at `wc/store` was never going to reach `/wc/v3/data/continents`. It is dead work on a hot path. But the same misfiring guard is the branch behind the "Cannot call constructor" fatals reported from WP Cloud: that crash was fixed in 2.5.1 by removing a `parent::__construct()` call against an ancestry that declares no constructor, and the reports come from installs still on 2.5.0 or older. A guard that went from "never true" to "true on most REST requests" is why a two-year-old bug still detonates fleet-wide, and it is worth fixing on those grounds rather than on performance. Ask whether core *has* the controller instead. `class_exists()` resolves it through WooCommerce's autoloader from `rest-api/Controllers/Version3/`, so the fallback now stays dormant on every supported WooCommerce (core has shipped this endpoint since WC 3.5) and still engages if it is ever genuinely missing. Also require `class-wc-connect-continents.php` inside the fallback. The controller constructs `WC_Connect_Continents` but the branch relied on `load_dependencies()` having required it first; making the fallback self-contained removes a latent class-not-found fatal on any path that reaches it earlier. Tests: one regression guard that reproduces the Store API request shape and asserts the bundled controller is never loaded, and one that pins the 2.5.1 constructor fix. BC: `WC_Connect_Loader::wc_api_dev_init()` keeps its signature and remains hooked to `rest_api_init`; both `WC_REST_Dev_Data_*` classes are untouched. The only behaviour change is that the plugin no longer registers a route on requests that could not have dispatched to it anyway, so no consumer can observe the difference.
…, not class-load state class_exists( ..., false ) reads process-global state that another test in the same file permanently sets via require_once, so the assertion only held under PHPUnit's default declaration order and failed spuriously under --order-by=random/reverse/defects. Assert the observable contract instead: no /wc/v3/data/continents route is registered.
CezaryDrewniak
left a comment
There was a problem hiding this comment.
Reviewed the follow-up and the fix holds up.
I ran the reset mutation ($non_taxable = array( $canonical_id => true )) against the updated seam test: it fails with the first exempt key missing from the map — precisely the WOOTAX-240 shape this test exists to catch. Suite green at 401/978, PHPCS clean (no new violations on either file), and the class file is byte-identical across the two commits, so no production risk. Also traced every consumer of the map and canonical ids — nothing else is affected.
Merging trunk brought in the woorelease bump that stamped 3.6.12 with its release date (2026-08-10) and moved Stable tag to 3.6.12. This branch's entry was written into that block while it was still unreleased, so the merge left an unshipped change listed under a release that already went out. Opens a new 3.6.13 - 2026-xx-xx block for it, matching what every first commit after a release in this repo does. No version headers are touched; woorelease owns Version, Stable tag and package.json, and fills the xx-xx date at release.
| require_once __DIR__ . '/../../classes/class-wc-connect-continents.php'; | ||
| require_once __DIR__ . '/../../classes/wc-api-dev/class-wc-rest-dev-data-controller.php'; | ||
| require_once __DIR__ . '/../../classes/wc-api-dev/class-wc-rest-dev-data-continents-controller.php'; |
There was a problem hiding this comment.
woocommerce-services.php:1321 adds require_once __DIR__ . '/classes/class-wc-connect-continents.php'; to the fallback branch, presented in the PR body as the fix for the observed Class "WC_Connect_Continents" not found error. But test_bundled_continents_controller_is_constructible requires that same file itself before constructing the controller, and test_wc_api_dev_init_defers_to_core_continents_controller returns at the new class_exists() guard and never enters the fallback branch. Deleting line 1321 from production leaves the entire suite green, so the test proves the controller is constructible GIVEN its dependency rather than that the fallback loads it.
| require_once __DIR__ . '/../../classes/class-wc-connect-continents.php'; | |
| require_once __DIR__ . '/../../classes/wc-api-dev/class-wc-rest-dev-data-controller.php'; | |
| require_once __DIR__ . '/../../classes/wc-api-dev/class-wc-rest-dev-data-continents-controller.php'; | |
| require_once __DIR__ . '/../../classes/wc-api-dev/class-wc-rest-dev-data-controller.php'; | |
| require_once __DIR__ . '/../../classes/wc-api-dev/class-wc-rest-dev-data-continents-controller.php'; |
There was a problem hiding this comment.
You're right that nothing pinned that require. I checked it the way you did and got the same result: delete line 1321 and the whole suite stays green, because neither test ever enters the fallback branch. Fixed in 8a1691a.
I did not apply the suggested diff, though, because I don't think it gets there. I tried it:
- on its own,
composer test -- --filter test_bundled_continents_controller_is_constructiblefails withClass "WC_Connect_Continents" not found - the full
composer testpasses
It passes in the suite only because tests/php/classes/test-class-wc-rest-connect-shipping-label-controller.php sorts earlier and loads that class for its own setup. So the test would be relying on process-global class-load state again — the thing your first comment (correctly) got rid of — and it still never enters the fallback branch, so line 1321 would stay unpinned either way.
What I did instead was make the branch reachable. wc_api_dev_init()'s "does core supply the controller?" question now lives in a small protected method, so a test can answer it "no" on a WooCommerce that does supply it. Everything else in the method runs for real: the three require_once calls, the controller construction, and register_routes(). The new test asserts both bundled routes show up on a route table where core's wc/v3 namespace is absent, so they can only have come from our fallback.
Mutation results, all with --filter test_wc_api_dev_init:
| Mutation | Result |
|---|---|
drop $continents->register_routes(); |
fails |
drop either wc-api-dev require |
fails, class not found |
drop the WC_Connect_Continents require |
fails, class not found |
| guard back to route-table-only (the bug this PR fixes) | fails, on the sibling test |
One honest limit: that third row only holds when the file runs alone. In a whole-suite run an earlier file has already loaded WC_Connect_Continents, so the missing require is invisible. Loading a file is process-global and monotonic, so the only way around it is a fresh process per test, and that is not affordable here — I measured PHPUnit's process-isolation annotation at ~26s for a single test (the child re-runs the WP/WC bootstrap), and that bootstrap reinstalls the shared test database underneath the tests that run after it. I wrote that limit into the test's docblock rather than leave it implied.
Suite is 404 tests / 985 assertions green, and PHPCS is unchanged on both files (206 violations / 29 sources before and after, same path).
… the controller Review pointed out that nothing pinned the fallback branch itself: the constructible test builds the bundled controller directly, so it proves the controller works given its dependency rather than that the branch loads it, and deleting the require_once from production left the suite green. Drive the branch instead. wc_api_dev_init()'s "does core supply the controller?" question moves into a protected method so a test can answer it "no" on a WooCommerce that does supply it — class_exists() cannot be made to answer otherwise within a process — while the rest of the method runs for real: its three require_once calls, the controller construction and register_routes(). The test asserts both bundled routes appear on a route table where core's wc/v3 namespace is absent, so they can only have come from the fallback. Mutation-checked: dropping register_routes(), either wc-api-dev require, or the WC_Connect_Continents require all fail the test when the file runs alone; reverting the guard to the old route-table-only logic fails the sibling test. The WC_Connect_Continents require is masked in a whole-suite run because an earlier test file loads that class for its own purposes — loading a file is process-global and monotonic, and per-test process isolation costs ~26s and reinstalls the shared test DB mid-run. Documented on the test rather than papered over. The constructible test keeps its own requires: dropping them, as suggested, makes it pass only when some earlier file happens to have loaded the class (green in the full suite, red under --filter), which is the order-dependence the earlier review round removed.
CezaryDrewniak
left a comment
There was a problem hiding this comment.
Reviewed this end to end: code, tests, and against the WC/WP internals. Looks good.
Verdict
Approve with one wording nit in the BC section (see below).
The fix is right: class_exists( 'WC_REST_Data_Continents_Controller' ) resolves through WooCommerce's autoloader (checked 9.4 and 10.4 — the file only exists under Version3/, no name collisions), so it's a definitive "does core provide the endpoint" check regardless of what the current request has loaded. The old route-table guard genuinely broke on WC 9.2+ lazy namespaces (wc_rest_should_load_namespace()), and the new guard keeps the fallback dormant everywhere it should while the route-table path stays as the genuine "core is too old" branch.
Tests — all verified
- Full suite:
OK (404 tests, 985 assertions). - All three new tests pass in isolation.
- Fail-first reproduced: with
trunk'swoocommerce-services.phptemporarily restored,test_wc_api_dev_init_defers_to_core_continents_controllerfails at line 137 (/wc/v3/data/continentspresent in the route table on awc/storerequest) — and it's the only failure in the whole suite (404 tests / 985 assertions), so nothing else regresses. - Reran the two guard tests on WooCommerce 9.4.0: green (
OK (2 tests, 6 assertions)).
PHPCS
No new violations on either changed file — zero hits in woocommerce-services.php:1300-1345 or in the new test methods. (The repo-wide run still reports the pre-existing backlog, unrelated to this PR.)
One BC note
The "no way for a consumer to observe the difference" sentence in the BC section is slightly stronger than reality. A site that filters wc_rest_should_load_namespace to force wc/v3 off even on v3 requests would previously have had the bundled fallback register and serve the route; with this change the endpoint 404s (alongside the rest of that namespace). That's a deliberate and sensible trade — the site already opted out of wc/v3 — but I'd soften that one sentence to acknowledge it rather than claim zero observable difference.
Description
WC_Connect_Loader::wc_api_dev_init()decides whether WooCommerce provides/wc/v3/data/continents— and if it thinks not, registers the bundledclasses/wc-api-dev/copy as a fallback. It made that decision by looking for the route in the current request's route table:That inference was correct when it was written and stopped being correct in WooCommerce 9.2. Core now registers the
wc/v3namespace lazily —Server::get_rest_namespaces()consultswc_rest_should_load_namespace(), which returnsfalsewhenever the current request targets a different known namespace (wc/store,wc-analytics,wc-admin,wc/v1,wc/v2,wc-telemetry,wc/private). On every one of those requests/wc/v3/data/continentsis legitimately absent even though core supplies the controller, so the guard passes and the fallback fires: tworequire_onces, a controller construction, and tworegister_rest_route()calls, on every block-cart and Analytics request for every shipping-enabled install.Nothing observable comes of that, and the PR should not be read as a performance fix. Route registrations last exactly one request, and a request aimed at
wc/storecan never dispatch to/wc/v3/data/continents— so no wrong answer is ever served, and on requests that do targetwc/v3core registers at priority 10, our guard sees the route, and we skip as we always have. What the fallback does on those other requests is dead work on a hot path: real and measurable, but microseconds.The case for fixing it is that the guard's logic is now wrong, and incorrect logic on a hot path is what let an already-fixed crash survive in production for two years — see below.
The fix is to ask whether core has the controller, not whether the route happens to be registered for this request.
class_exists( 'WC_REST_Data_Continents_Controller' )resolves through WooCommerce's autoloader, whosewc_rest_branch searchesincludes/rest-api/Controllers/Version{1,2,3}/— so it is definitive regardless of what has been loaded so far. Core has shipped this endpoint since WC 3.5 (2018), well under this repo's WC 9 floor, so the fallback now stays dormant everywhere it should and still engages if the endpoint is ever genuinely missing. The old route-table check is kept underneath as that genuine "core is too old" path.This is also the branch behind the WP Cloud "Cannot call constructor" fatals
WOOTAX-299 reports ~1,413
Uncaught Error: Cannot call constructorfatals across 56 WP Cloud sites, thrown fromclass-wc-rest-dev-data-continents-controller.php:47, and hypothesises that a WooCommerce core base-controller constructor changed under us. That diagnosis does not hold, and this PR is not the fix for it — but it is the fix for what kept detonating it.parent::__construct()call. Current stable is 3.6.11.line:47facet is a version fingerprint. In 2.5.0 line 47 wasparent::__construct();. Post-fix, line 47 is$this->continents = new WC_Connect_Continents();. PHP emitsCannot call constructoronly from the explicitParent::__construct()call form against a constructor-less class —new Foo()on such a class is perfectly legal. So the reported rows can only come from installs still on ≤ 2.5.0.WC_REST_Dev_Data_Controller→WC_REST_Controller→WP_REST_Controller— has ever declared a constructor. That call was unconditionally fatal from the day it was written, on every WooCommerce and every PHP 7/8. It only looked version-dependent because the branch reaching it is.So a two-year-old, already-fixed bug still fires fleet-wide because WC 9.2 turned its trigger from "essentially never" into "most REST requests".
This PR does not help the 56 sites in that report — they crash because they run a 2.5-year-old plugin, and the only cure is updating it. The one claim in the diagnosis not yet verified from primary data is that those rows really do carry plugin version ≤ 2.5.0; the Grafana query has no version facet today. That question is tracked separately so it outlives this PR — see Related issue(s).
Second, smaller fix: the fallback did not require its own dependency
WC_REST_Dev_Data_Continents_Controller::__construct()builds aWC_Connect_Continents, but the branch only required the two controller files and relied onload_dependencies()having requiredclass-wc-connect-continents.phpearlier. In production it always has, so this is latent rather than live — but the first test run surfaced it as a concreteError: Class "WC_Connect_Continents" not foundat that same line 47. The fallback now requires it itself and is self-contained.Since review, that branch is covered by
test_wc_api_dev_init_registers_bundled_continents_route_when_core_lacks_it, which stubs the newcore_provides_continents_controller()seam tofalseand runs the realwc_api_dev_init(), asserting both bundled routes register. Dropping any of the branch's threerequire_oncecalls fails that test when the file runs on its own. TheWC_Connect_Continentsone is masked in a whole-suite run, because an earlier test file has already loaded that class for its own setup — loading a file is process-global and monotonic, so the only way to observe it is a fresh process per test, which this suite cannot afford (PHPUnit's process-isolation annotation costs ~26s for one test and its child bootstrap reinstalls the shared test database underneath the tests that follow). That limit is documented on the test.Backward compatibility
Public and externally exposed surface touched. No signature changes.
WC_Connect_Loader::wc_api_dev_init()keeps its signature, its visibility, and itsrest_api_initpriority-9999 hook. It is still registered under the sameshould_load_shipping_features()gate.WC_REST_Dev_Data_Continents_ControllerandWC_REST_Dev_Data_Controllerare untouched — not renamed, not deprecated, not removed. Both are global-namespace classes that out-of-repo code could subclass or instantiate, and both still load and behave identically when the fallback engages.wc/store/v1/cartrequest only exists for the lifetime of that request, and that request cannot route to/wc/v3/data/continents. There is no way for a consumer to observe the difference. On a request that does targetwc/v3,wc_rest_should_load_namespace()returnstrue, core registers its own controller, and the endpoint responds exactly as before — from core's implementation, which it already did.class_exists()andrequire_onceonly; noWC()->…dereference, no$post, no$wp_query, no session or cart access. Safe on the REST, cron, WP-CLI and webhook paths.__DIR__; no URL is constructed.should_load_shipping_features()is true, i.e. on grandfathered installs. Nothing is removed from that surface — the fallback still exists and still works.protected WC_Connect_Loader::core_provides_continents_controller(). Additive and protected, so no existing signature or visibility changes; the only way it can break a consumer is a subclass that already declares a member of that exact name with incompatible visibility. Behaviour is identical — it returns the sameclass_exists( 'WC_REST_Data_Continents_Controller' )the guard evaluated inline before, and it exists so the fallback branch is reachable from a test.Related issue(s)
WOOTAX-299 — auto-linked via the branch name; this PR carries the code half of it.
WOOTAX-323 — the remaining half: confirm from Grafana that the affected rows carry plugin version ≤ 2.5.0. Fleet work rather than code work, and it is what decides whether ~1,413 fatals/week are already remediated or whether there is a live crash we have not found.
Same alert family, and both are worth re-checking with the line-number-as-version-fingerprint technique above before any code is written for them:
Class Automattic\WooCommerce\StoreApi\StoreApi not foundFailed opening required 'jetpackclass-wc-connect-api-client.php'Follow-up worth filing: retire the
classes/wc-api-dev/shim entirely. Its own docblock says "Delete this when the 'v3' REST API is included in all the WC versions we support", and it is. Per AGENTS.md that has to be a deprecation rather than a deletion, since both classes and the loader method are public globals.Steps to reproduce & screenshots/GIFs
Automated — this is the primary evidence.
composer test→ 404 tests / 985 assertions green, zero regressions (403 / 982 before the review-round test was added).woocommerce-services.phpand re-runcomposer test -- --filter test_wc_api_dev_init_defers_to_core_continents_controller.It fails — and fails at the exact crash site named in the ticket:
mockLoader(), which skipsload_dependencies()— which is what prompted the second fix above.)phpcs.xml.dist --report=sourceover both changed files reports206 SNIFF VIOLATIONS … IN 29 SOURCESbefore and after, with identical per-sniff counts.Manual, on a store with shipping features enabled (
should_load_shipping_features()true), WooCommerce ≥ 9.2:error_log( 'wc-api-dev fallback fired' );inside the! isset( $existing_routes[…] )branch ofwc_api_dev_init().wc/store/wc-analyticsREST requests).debug.logon essentially every such request. After: it never appears.GET /wp-json/wc/v3/data/continentsas an admin still returns the full continents payload in both cases — served by core's controller, as it already was.Checklist
changelog.txtentry addedreadme.txtentry added