Architecture

June 5, 2026

A technical tour of how Super Speedy AJAX Prices is put together — what each component does, the interesting design decisions, and the detail of every notable part.

Last updated for plugin version 1.3.


The core idea (read this first)

Full-page caching and personalised WooCommerce pricing are fundamentally in tension: a cached HTML page is identical for every visitor, but the correct price depends on the visitor (geolocation, tax location, VAT exemption, dynamic-pricing rules, currency). Most stores resolve this by excluding product/shop pages from the cache — which throws away the biggest performance win.

This plugin resolves it the other way around:

  1. At render time (the moment that gets cached), each product’s price HTML is replaced with a placeholder that still contains the cached price (so the page looks right and reserves the correct layout space).
  2. After the cached page loads in the browser, a single batched AJAX request fetches the real price (and optionally stock) for every product on the page, computed fresh for the current visitor.
  3. The JS swaps the cached price for the visitor-correct one in a single frame.

So the page can be fully cached and still show per-visitor-correct prices. Everything in the plugin exists to serve that flow.


Component summary

Component File(s) Purpose
Bootstrap super-speedy-ajax-prices.php Defines constants, loads the settings submodule, instantiates the main class on plugins_loaded (guarded by a WooCommerce check).
Main class includes/class-wc-ajax-pricing.php The whole runtime: registers hooks, rewrites price HTML into placeholders, serves the AJAX endpoint, handles stock tagging, and the VAT-exempt tax fix.
Front-end script assets/js/wc-ajax-pricing.js Collects placeholder product IDs, fires the batched AJAX request, swaps prices/stock into the DOM, restores cached HTML on failure.
Admin settings admin/settings-page.php Settings API page under the Super Speedy menu (exclusion toggles, VAT fix, stock refresh, loading text).
Product-type adapters includes/adapters/class-ssap-adapter-bundle.php, …-composite.php Optional, self-registering enrichers that add Bundles/Composite per-child stock data to the AJAX payload via a filter. No-ops when those plugins are absent.
Shared framework super-speedy-settings/ (git submodule) Cross-plugin licensing, update-checking, and the top-level “Super Speedy” admin menu. Shared by every Super Speedy plugin.
Test rig .tests/ Playwright + TypeScript end-to-end tests against a real local WooCommerce, plus a dev-only “test mode” mu-plugin.

Interesting / unusual aspects

These are the design decisions worth knowing before you change anything:


Bootstrap & lifecycle

super-speedy-ajax-prices.php is intentionally thin:

  1. Aborts if ABSPATH is undefined (direct-access guard).
  2. Loads the settings submodule and calls SuperSpeedySettings_1_0::init(...) to register this plugin with the shared framework (slug, version, file).
  3. Defines constants: WC_AJAX_PRICING_PATH, …_URL, …_VERSION (read from the plugin header via get_file_data, so the header is the single source of truth for the version), plus two define()-overridable behaviour flags:
  4. WC_AJAX_PRICING_VERBOSE_LOGGING (default false) — gates the tax-debug logging.
  5. WC_AJAX_PRICING_TRUST_VAT_EXEMPT (default false) — see VAT-exempt fix.
  6. On plugins_loaded, wc_ajax_pricing_init() checks class_exists('WooCommerce'). If WooCommerce is missing it shows an admin notice and bails; otherwise it news up WC_AJAX_Pricing and calls ->init().

->init() is where all hooks are registered (see below). It also require_onces the admin settings page and the two adapter files, then calls each adapter’s maybe_register().


Main class: WC_AJAX_Pricing

The single runtime class. init() wires up everything; the rest of the class is the handlers.

Hooks registered

Hook Method When active
wp_enqueue_scripts register_scripts Always (front end)
woocommerce_get_price_html replace_price_with_placeholder Always
woocommerce_get_stock_html tag_stock_html Only if stock refresh enabled
woocommerce_loop_add_to_cart_link tag_loop_add_to_cart Only if stock refresh enabled
wp_ajax_get_wc_prices / wp_ajax_nopriv_get_wc_prices get_ajax_prices Always
woocommerce_variation_prices_{price,regular_price,sale_price} adjust_variation_price_for_tax_exempt Only if wc_ajax_pricing_vat_exempt_fix = yes
woocommerce_get_variation_prices_hash add_tax_location_to_prices_hash Only if wc_ajax_pricing_vat_exempt_fix = yes

Script registration & the localized params

register_scripts() skips the admin area, then registers and enqueues wc-ajax-pricing.js, localizing a wc_ajax_pricing_params object containing:

  • ajax_urladmin-ajax.php.
  • noncewp_create_nonce('wc-ajax-pricing-nonce').
  • loading_text, disable_with_cart, refresh_stock — settings-derived.

Crucially no per-visitor data is localized, because the localized values are baked into the cached HTML and would otherwise leak/staleness across visitors. Visitor-specific decisions (cart contents) are made client-side from cookies instead.


Price placeholder mechanism

replace_price_with_placeholder($price_html, $product) is the render-time half. Order of checks:

  1. Bail (return original) in admin.
  2. Bail if the product status isn’t publish (the security gate).
  3. Bail if wc_ajax_pricing_disable_for_logged_in = yes and the user is logged in.
  4. Bail if wc_ajax_pricing_disable_with_cart = yes and the cart is non-empty.
  5. Otherwise wrap the price:
<span class="ajax-price[ ajax-price-variable]" data-product-id="123">
  <span class="ajax-price-placeholder">{cached price HTML, or &nbsp; if empty}</span>
</span>
  • ajax-price-variable is added for variable products so the JS/consumers can distinguish them.
  • The &nbsp; fallback for empty price HTML is what prevents layout shift on “Call for price”/no-price products — the placeholder still reserves one line.
  • The product ID is the only dynamic value and is esc_attr‘d (it’s already an int from get_id()).

The cached inner HTML is what stays visible until the AJAX swap.


The AJAX endpoint: get_ajax_prices

The runtime half, served for both logged-in and logged-out visitors.

  1. check_ajax_referer('wc-ajax-pricing-nonce', 'nonce') (see security model for why this is weak-by-design and acceptable).
  2. Read product_ids from $_POST, defensively: non-array input is coerced to an empty array (avoids a PHP 8 TypeError), then IDs are absint‘d, de-duplicated, zero-filtered, and capped at apply_filters('ssap_max_products_per_request', 200).
  3. For each ID: load via wc_get_product(), skip if it doesn’t exist or isn’t publish, then collect get_price_html() (which runs WooCommerce’s full geolocation + pricing-rule stack for the current visitor) and an is_variable flag.
  4. If stock refresh is on, also collect get_stock_payload($product) per product.
  5. Respond with wp_send_json_success(['prices' => …, 'stock' => …]).

The response is keyed by product ID so the JS can match placeholders. Because get_price_html() is called inside the live AJAX request, the price reflects the real visitor — that’s the whole trick.


Stock refresh

Opt-in via wc_ajax_pricing_refresh_stock. When on, the plugin also keeps live stock state and Add-to-Cart buttons correct on cached pages.

stock_refresh_enabled()

The single gate consulted both at render time (to decide whether to tag markup) and in the AJAX handler (to decide whether to build the stock payload). It returns false on a real admin screen but true during admin-ajax/REST (the is_admin() gotcha), false when the setting is off, and false under the same logged-in / cart-has-items exclusions as prices — so the price and stock halves of a response always agree.

Tagging (render time)

  • tag_stock_html() injects ajax-stock + data-product-id into WooCommerce’s stock <p> via a single preg_replace.
  • tag_loop_add_to_cart() adds a stable ssap-add-to-cart hook class + data-ssap-product-id to the loop Add-to-Cart anchor (needed because out-of-stock “Read more” variants use a different class set). It’s idempotent — it won’t double-tag.

Payload (runtime)

get_stock_payload($product) returns availability_html, is_in_stock, is_purchasable, and add_to_cart_html (rendered by briefly swapping $GLOBALS['product'] and capturing woocommerce_template_loop_add_to_cart() via output buffering). The array is passed through the ssap_stock_data_for_product filter before return — the adapter extension point.


Product-type adapters

Two self-registering classes that enrich the stock payload for complex product types, each a no-op unless its parent plugin is loaded:

  • SSAP_Adapter_Bundlemaybe_register() only hooks in if WC_Product_Bundle exists. For bundle products it adds a bundled_items map (per-child product_id, is_in_stock, is_purchasable, is_optional, max_stock, sold_individually) plus the bundle’s roll-up stock status/quantity.
  • SSAP_Adapter_Composite — only hooks in if WC_Product_Composite exists. For composite products it adds a components map (per-component title, optional flag, and per-option in-stock/purchasable state, plus the default option ID).

Both guard every call with method_exists() so they tolerate API differences across versions of those plugins. The design point: the core has zero compile-time dependency on Bundles/Composite — it just fires a filter, and adapters decide what to add. New product types = new adapter file registered in init().


VAT-exempt price fix

An opt-in correction (wc_ajax_pricing_vat_exempt_fix) for stores configured with “enter prices inclusive of tax” that also sell into VAT-free regions. By default WooCommerce still shows the base-country VAT baked into archive/variation prices for those customers; this fix strips it.

Two cooperating hooks:

  1. adjust_variation_price_for_tax_exempt() on the three woocommerce_variation_prices_* filters. It only acts when tax is enabled, prices include tax, the product is taxable, and customer_is_tax_exempt_for_class() returns true; then it computes the embedded base tax (WC_Tax::calc_tax(..., true)) and subtracts it. It deliberately skips single-visible-variation products because WooCommerce 10.4+ already adjusts those natively (running both would strip the tax twice).
  2. add_tax_location_to_prices_hash() on woocommerce_get_variation_prices_hash. This appends the customer’s resolved tax location to WooCommerce’s variation-price cache key. Without it, the location-adjusted price would be cached under a location-agnostic key and leak across visitors — the subtle but essential half of the feature.

customer_is_tax_exempt_for_class() resolves the taxable address (respecting woocommerce_tax_based_on), short-circuits to “not exempt” when the location matches the store base, and otherwise treats “no matching tax rates for this class at this location” as exempt. The WC_AJAX_PRICING_TRUST_VAT_EXEMPT constant controls whether an explicit is_vat_exempt flag is also trusted (off by default, because of a known WooCommerce edge case noted in the code).

A debug_log() helper (gated by WC_AJAX_PRICING_VERBOSE_LOGGING, off by default) traces this logic to the error log.


Front-end script

assets/js/wc-ajax-pricing.js, an IIFE over jQuery. Flow:

  1. collectProductIds() — gathers IDs from .ajax-price:not(.ajax-price-loaded) (and, when stock refresh is on, .ajax-stock and .ssap-add-to-cart not-yet-loaded markers). The :not(.…-loaded) filter means a re-init only fetches freshly-appended products, not the whole page again.
  2. initAjaxPricing() — bails if shouldDisableAjaxPricing() (cart cookie present and the disable-with-cart param is set) or if there are no IDs. Records each placeholder’s current HTML in data-original-content for the failure path, then calls fetchPrices().
  3. fetchPrices() — one POST to get_wc_prices with all IDs. On success, updatePrices() and (if present) updateStock(). On error or an unsuccessful body, restoreOriginalPrices() puts the cached HTML back.
  4. updatePrices() sets each placeholder’s innerHTML to the returned price_html and marks it ajax-price-loaded; then fires wc_ajax_pricing_updated on document.body.
  5. updateStock() replaces tagged stock nodes and Add-to-Cart links with the server-rendered fresh markup, marking them ajax-stock-loaded; then fires wc_ajax_inventory_updated with the stock map.

It re-initialises on wc_fragments_refreshed, found_variation, and the auto-infinite-scroll-complete event (the Auto Infinite Scroll integration). The two document.body events are the public JS extension points (documented in the Developer Reference KB).

Note: the price/stock HTML written via innerHTML is server-generated WooCommerce markup keyed by integer product ID — not attacker-controlled — so it isn’t an XSS vector.


Admin settings

admin/settings-page.php uses the WordPress Settings API the standard way: a wc-ajax-pricing-settings submenu under the Super Speedy top-level menu (manage_options), registered options each with a sanitize_text_field callback, and settings_fields() providing nonce + referer checks. Options:

  • wc_ajax_pricing_disable_with_cart
  • wc_ajax_pricing_disable_for_logged_in
  • wc_ajax_pricing_vat_exempt_fix
  • wc_ajax_pricing_refresh_stock
  • wc_ajax_pricing_loading_text

These are the same options read across the main class. The page also renders static “How to use” / caching-recommendation / troubleshooting cards.


Shared framework: super-speedy-settings (submodule)

A git submodule shared by every Super Speedy plugin, so fixes propagate to all of them. Responsibilities:

  • The top-level “Super Speedy” admin menu and a licence table listing every installed Super Speedy plugin with install/licence status and changelog deltas.
  • Licence checking against dev.superspeedyplugins.com, cached in transients, with a “Recheck Licenses” flow that force-bypasses both local and server caches.
  • Update checking via the bundled YahnisElsts plugin-update-checker library, injecting the licence key + domain into the update request.

It’s a version-guarded singleton: SuperSpeedySettings_1_0 extends the abstract SuperSpeedySettings, and init() only swaps in a new instance when the calling class’s version is higher than the current instance’s. Each plugin registers itself (slug/version/file) so the framework knows what’s installed regardless of which plugin’s copy of the submodule actually boots.

This is shared, cross-plugin code. Changes here belong in the submodule’s own repo and changelog, not this plugin’s. (The security review notes a couple of hardening items that live here.)


Test rig

.tests/ is a Playwright + TypeScript suite that runs against a real local WooCommerce — no mocked Woo. Highlights:

  • run-tests.sh is the entry point: it refuses to run unless siteurl is a dev URL and SSAP_TEST_MODE is defined, symlinks the test mu-plugin into mu-plugins/, seeds idempotent fixtures (SKUs prefixed ssap-test-), snapshots your SSAP settings and restores them on exit, and forwards extra args to Playwright.
  • mu/ssap-test-mode.php is a dev-only mu-plugin exposing request-time levers (?ssap_test_country=, and ?ssap_test_user= gated behind both SSAP_TEST_MODE and SSAP_ALLOW_USER_SWITCH) plus a /wp-json/ssap-test/v1/whoami probe. It only activates on reserved dev TLDs.
  • Specs cover the price flow (placeholders, single batched request, failure restore, no layout shift), stock refresh (on/off baseline, cache-skew inversion), exclusions, the AIS integration, the Bundles/Composite adapters (self-skip without those plugins), and a real WP-Super-Cache end-to-end spec (self-skips unless that plugin is active).

The suite found three real plugin defects while being written (universal enqueue, the is_admin()/DOING_AJAX gate, and a stock/add-to-cart class collision).


Security model

A quick consolidation of the security-relevant design, since it’s spread across components:

  • Published-only exposure. Both replace_price_with_placeholder() and get_ajax_prices() skip any product whose status isn’t publish, so draft/pending/private/scheduled prices and stock can’t be enumerated through the public endpoint. Admins still see correct server-rendered prices on unpublished products — they’re just not AJAX-refreshed.
  • Per-request cap. ssap_max_products_per_request (default 200) bounds how much work one request can force.
  • Defensive input handling. Non-array product_ids can’t crash the handler; IDs are integer-cast, de-duplicated, and zero-filtered.
  • Nonce is not a real boundary here. Because the page is cached, the nonce is a shared constant for logged-out visitors. That’s acceptable only because the endpoint returns public data and changes no state — do not add state-changing behaviour behind it.
  • Output escaping. Server-side, the only dynamic value placed into markup is the integer product ID, via esc_attr. Client-side innerHTML writes use trusted WooCommerce-rendered HTML.
×
1/1