| @@ -66,12 +66,12 @@ | ||
| 66 | 66 | * | 7 | `woocommerce_pos_sync_proxy_response` | `Sync\Revision::stamp_proxy_revisions` (via `Augmentation_Pipeline::install()`) | **9** | **ORDER-CRITICAL** | Between `Meta_Normalizer` (5) and the uuid/digest stampers (10). Revision must hash the normalized-but-not-yet-augmented payload. | |
| 67 | 67 | * | 8 | `woocommerce_pos_sync_proxy_response`, `..._serialized_product` | `Proxy_Uuid_Stamper`, `Integrity_Digest` digest stampers, pipeline projections | 10 | order-critical (by number) | Preserved verbatim from the hand-wiring the pipeline replaced, so third-party code hooking either public filter still runs where it always did. | |
| 68 | 68 | * | 9 | `woocommerce_before_product_object_save`, `woocommerce_before_product_variation_object_save` | `Sync\Pos_Uuid::stamp_on_save` | 10 | irrelevant | Default. The HOOK is the design (before the data store writes, so the uuid lands in the same save); the number is not. Registered unconditionally — identity is core, not an observer. | |
| 69 | 69 | * | 9a | `untrashed_post`, `woocommerce_untrash_order` | `Sync\Pos_Uuid::recheck_ownership_after_untrash`, `::recheck_order_ownership_after_untrash` | 10 | irrelevant | Default. Re-proves uuid ownership when a record leaves the trash — the one seam a native restore passes through (#1805, ADR 0038). Unconditional for the same reason as row 9; the journal and digest observers (rows 10, 12) share both hooks at the same priority once the latch is set, and nothing depends on the order. | |
| 70 | - * | 10 | 32 catalogue/customer/order hooks | `Sync\Sync_Journal` (33 callbacks) | 10 | irrelevant | Default throughout. | | |
| 70 | + * | 10 | 32 catalogue/customer/order hooks, plus `shutdown` | `Sync\Sync_Journal` (34 callbacks) | 10 (`shutdown` at `PHP_INT_MAX`) | `shutdown`: order-critical (by number) | Default throughout. `woocommerce_update_order` only MARKS the order dirty; the `hook:update` row lands on `flush_pending_order_updates()` — at `shutdown`, before any other-origin row for that order, or when a different order is saved — so one online checkout writes one update row, not eleven. The shutdown flush runs LAST because WooCommerce saves the customer at 10 and the session at 20; a save those trigger after the flush is written immediately. | | |
| 71 | 71 | * | 10b | `delete_option` plus `pre_update_option_*`, `update_option_*`, `add_option_*`, `delete_option_*` for the two `Pos_Visibility::source_options()` | `Sync\Visibility_Observer` (9 callbacks) | 10 | irrelevant | Default. Appends the journal row for a record entering or leaving the POS servable set — the transition the sequence-log stream relies on, since it drops a hidden record's update rows. `delete_option` is the generic PRE-delete action (the per-option form fires after) and is gated on the option name inside the callback. Registered after `Sync_Journal` only because it writes through it; the constructor also runs the observer's one-time tombstone seed. | |
| 72 | 72 | * | 11 | `wcpos_sync_journal_purge` | `Sync\Sync_Journal_Purge::run_purge` | 10 | irrelevant | Cron callback; sole listener. This call also SCHEDULES the daily event. | |
| 73 | - * | 12 | 21 catalogue/customer/order hooks (a subset of row 10's) | `Sync\Integrity_Digest` | 10 | unknown | Default. Shares every one of its hooks with `Sync_Journal` at the same priority, so the journal always runs first — no code found that depends on that, but nothing pins it either. | | |
| 73 | + * | 12 | 21 catalogue/customer/order hooks (a subset of row 10's), plus `shutdown` | `Sync\Integrity_Digest` | 10 (`shutdown` at `PHP_INT_MAX`) | unknown | Default. Shares every one of its hooks with `Sync_Journal` at the same priority, so the journal always runs first — no code found that depends on that, but nothing pins it either. Every save — product, variation, customer, order — only MARKS the digest dirty; the upsert lands on `flush_pending_digests()` at `shutdown`, before any `Digest_Index::read_digests()`, or when the queue holds 50 records. | | |
| 74 | 74 | * | 13 | `init` | `Init::init` | 10 | **ORDER-CRITICAL, CROSS-PLUGIN** | Default. **Pro registers its own `init` at 20** (`woocommerce-pos-pro/includes/Init.php:32`) so free's services exist first. Raising free's number silently breaks Pro; nothing on either side tests it. | |
| 75 | 75 | * | 14 | `rest_api_init` | `Init::init_rest_api` | **20** | **ORDER-CRITICAL, CROSS-PLUGIN** | Free's own reason: unknown — the number dates to the initial commit (8f2b9eac, 2021-03-16). It is load-bearing anyway: **Pro registers `rest_api_init` at 9**, commented "Before the free version" (`woocommerce-pos-pro/includes/Init.php:33`). Untested on both sides. | |
| 76 | 76 | * | 15 | `query_vars` | `Init::query_vars` | 10 | irrelevant | Default; appends one var. | |
| 77 | 77 | * | 16 | `pre_update_option_woocommerce_pos_pro_settings_license` | `Init::remove_license_transient` | 10 | irrelevant | Default. The reentrancy guard, not the priority, is what makes it safe (f33b8d655). | |
| @@ -443,41 +443,159 @@ | ||
| 443 | 443 | } |
| 444 | 444 | } |
| 445 | 445 | |
| 446 | 446 | /** |
| 447 | - * Common initializations. | |
| 447 | + * Groups constructed so far in this request (test seam; see constructed_groups()). | |
| 448 | + * | |
| 449 | + * @var array<string, bool> | |
| 448 | 450 | */ |
| 451 | + private static array $constructed = array(); | |
| 452 | + | |
| 453 | + /** | |
| 454 | + * Common initializations, by request lane. | |
| 455 | + * | |
| 456 | + * Every request gets the services whose hooks WooCommerce consults on a | |
| 457 | + * plain shopper page BEFORE any order exists: translations, the product | |
| 458 | + * visibility filters, the order statuses and the read-side order filters | |
| 459 | + * (My Account renders POS orders), the gateway registration (WooCommerce | |
| 460 | + * builds its gateway list on cart pages) and the reserved-stock filter | |
| 461 | + * (POS drafts must reduce online availability at add-to-cart time). | |
| 462 | + * | |
| 463 | + * Everything else is constructed only on the lanes that use it, and the | |
| 464 | + * order-event services additionally on the first order write of ANY request | |
| 465 | + * ({@see ensure_order_services()}), so the lane classifier is an | |
| 466 | + * optimisation rather than a correctness gate. Measured 2026-09-03: a | |
| 467 | + * storefront page loaded ~80 plugin files and 22 objects for hooks that | |
| 468 | + * never fire there (see .claude/research/2026-09-03-lazy-service-construction-spec.md). | |
| 469 | + */ | |
| 449 | 470 | private function init_common(): void { |
| 471 | + self::$constructed['always'] = true; | |
| 472 | + | |
| 450 | 473 | // init the Services. |
| 451 | 474 | SettingsService::instance(); |
| 452 | 475 | AuthService::instance(); |
| 453 | - Extensions::instance(); | |
| 454 | - Receipt_Snapshot_Store::instance(); | |
| 455 | 476 | |
| 456 | - // init other functionality needed by both frontend and admin. | |
| 477 | + // Needed on every lane, including a plain storefront page. | |
| 457 | 478 | new i18n(); |
| 458 | 479 | new Gateways(); |
| 459 | 480 | new Products(); |
| 460 | 481 | new Orders(); |
| 461 | - new Emails(); | |
| 462 | - new Templates(); | |
| 463 | 482 | Services\Stock_Validator::instance(); |
| 483 | + | |
| 484 | + if ( Services\Request_Lane::is_storefront() ) { | |
| 485 | + // Order-event services arrive on the first order write, if any. | |
| 486 | + self::arm_order_services(); | |
| 487 | + return; | |
| 488 | + } | |
| 489 | + | |
| 490 | + self::ensure_order_services(); | |
| 491 | + self::construct_pos_services(); | |
| 492 | + } | |
| 493 | + | |
| 494 | + /** | |
| 495 | + * Services only POS, admin, REST, cron and CLI requests use. | |
| 496 | + */ | |
| 497 | + private static function construct_pos_services(): void { | |
| 498 | + if ( isset( self::$constructed['pos'] ) ) { | |
| 499 | + return; | |
| 500 | + } | |
| 501 | + self::$constructed['pos'] = true; | |
| 502 | + Extensions::instance(); | |
| 464 | 503 | new Services\Decimal_Quantities(); |
| 465 | 504 | new Services\Customer_Meta_Parity(); |
| 505 | + } | |
| 506 | + | |
| 507 | + /** | |
| 508 | + * Hook the order-event services to the first order write of the request. | |
| 509 | + * | |
| 510 | + * Every WooCommerce order write — create, update, status transition, | |
| 511 | + * `payment_complete()`, refund — goes through `WC_Abstract_Order::save()`, | |
| 512 | + * which fires `woocommerce_before_order_object_save` before the data store | |
| 513 | + * writes and before `woocommerce_new_order` / `woocommerce_order_status_changed` | |
| 514 | + * / `woocommerce_payment_complete` fire. Priority 0 there means every | |
| 515 | + * observer exists before any order is written — on a webhook, a cron | |
| 516 | + * spawned from a page view, a third-party plugin creating an order on | |
| 517 | + * `template_redirect`, or a lane the classifier got wrong. Nothing in the | |
| 518 | + * order group listens to trash or delete, so those need no arming. | |
| 519 | + */ | |
| 520 | + private static function arm_order_services(): void { | |
| 521 | + add_action( 'woocommerce_before_order_object_save', array( self::class, 'ensure_order_services' ), 0, 0 ); | |
| 522 | + } | |
| 523 | + | |
| 524 | + /** | |
| 525 | + * Construct the order-event services exactly once per request. | |
| 526 | + * | |
| 527 | + * Idempotent and safe to call after `init`; each service handles its own | |
| 528 | + * late registration. Fires `woocommerce_pos_order_services_ready` once so | |
| 529 | + * Pro and extensions can construct their own order-event services at the | |
| 530 | + * same moment on every lane. | |
| 531 | + */ | |
| 532 | + public static function ensure_order_services(): void { | |
| 533 | + if ( isset( self::$constructed['order'] ) ) { | |
| 534 | + return; | |
| 535 | + } | |
| 536 | + self::$constructed['order'] = true; | |
| 537 | + | |
| 538 | + Receipt_Snapshot_Store::instance(); | |
| 539 | + new Emails(); | |
| 540 | + new Templates(); | |
| 466 | 541 | new Services\Print_Job_Service(); |
| 467 | 542 | new Services\Cloud_Print_Trigger_Service(); |
| 468 | 543 | new Services\Cloud_Print_Submit_Service(); |
| 469 | 544 | new Services\Cloud_Print_Relay_Service(); |
| 545 | + | |
| 546 | + /** | |
| 547 | + * Fires once per request when the POS order-event services exist: | |
| 548 | + * eagerly on POS, admin, REST, cron and CLI requests (from this | |
| 549 | + * plugin's `init` callback at priority 10), and on a storefront | |
| 550 | + * request the moment the first order is about to be written. | |
| 551 | + * | |
| 552 | + * Because the eager firing happens at `init` priority 10, a listener | |
| 553 | + * added later than that (for example from another plugin's `init` | |
| 554 | + * callback at priority 20) must check `did_action()` first and | |
| 555 | + * construct immediately when the action has already fired. | |
| 556 | + * | |
| 557 | + * @since 1.10.8 | |
| 558 | + */ | |
| 559 | + do_action( 'woocommerce_pos_order_services_ready' ); | |
| 470 | 560 | } |
| 471 | 561 | |
| 472 | 562 | /** |
| 563 | + * Which service groups this request has constructed: 'always', 'order', 'pos'. | |
| 564 | + * | |
| 565 | + * @internal Test seam. | |
| 566 | + * | |
| 567 | + * @return string[] | |
| 568 | + */ | |
| 569 | + public static function constructed_groups(): array { | |
| 570 | + return array_keys( self::$constructed ); | |
| 571 | + } | |
| 572 | + | |
| 573 | + /** | |
| 574 | + * Forget which groups were constructed. Tests only. | |
| 575 | + * | |
| 576 | + * @internal | |
| 577 | + */ | |
| 578 | + public static function reset_request_state(): void { | |
| 579 | + self::$constructed = array(); | |
| 580 | + Services\Request_Lane::reset(); | |
| 581 | + } | |
| 582 | + | |
| 583 | + /** | |
| 473 | 584 | * Frontend specific initializations. |
| 474 | 585 | */ |
| 475 | 586 | private function init_frontend(): void { |
| 476 | - if ( ! is_admin() ) { | |
| 587 | + if ( is_admin() ) { | |
| 588 | + return; | |
| 589 | + } | |
| 590 | + // The public receipt shortcode and the My Account receipt action are | |
| 591 | + // storefront features; they construct the template services when used. | |
| 592 | + new Storefront_Receipts(); | |
| 593 | + if ( ! Services\Request_Lane::is_storefront() ) { | |
| 594 | + // The POS routes (rewrite rules, checkout context, order-pay and | |
| 595 | + // coupon forms) only matter on requests the classifier saw as POS. | |
| 477 | 596 | new Template_Router(); |
| 478 | 597 | new Form_Handler(); |
| 479 | - new Storefront_Receipts(); | |
| 480 | 598 | } |
| 481 | 599 | } |
| 482 | 600 | |
| 483 | 601 | /** |
| @@ -512,7 +630,17 @@ | ||
| 512 | 630 | } |
| 513 | 631 | |
| 514 | 632 | // wePOS alters the WooCommerce REST API, breaking the expected schema |
| 515 | 633 | // It's very bad form on their part, but we need to work around it. |
| 516 | - new Integrations\WePOS(); | |
| 634 | + // Its only hook is admin_init (a conflict notice), so admin lane only. | |
| 635 | + if ( is_admin() ) { | |
| 636 | + new Integrations\WePOS(); | |
| 637 | + } | |
| 638 | + | |
| 639 | + // WooCommerce Tax - https://wordpress.org/plugins/woocommerce-services/ | |
| 640 | + // Its class exists whenever the plugin is active, but its callbacks are | |
| 641 | + // only hooked when automated taxes are on and the store country is | |
| 642 | + // supported, so the integration looks them up on the hooks at | |
| 643 | + // recalculation time instead of gating on the class here. | |
| 644 | + new Integrations\WooCommerce_Tax(); | |
| 517 | 645 | } |
| 518 | 646 | } |