Architecture
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.
Table of Contents
- The core idea (read this first)
- Component summary
- Interesting / unusual aspects
- Super Speedy Ajax Prices
- Super Speedy Chat
- Super Speedy Pack
- Scalability Pro
- Super Speedy Search
- Super Speedy Filters
- Super Speedy Imports
- External Images
- Auto Infinite Scroll
- Super Speedy Sitemaps
- Faster Datafeedr Product Sets
- Fullscreen Typeform
- Datafeedr Merchant Research
- Bootstrap & lifecycle
- Main class: WC_AJAX_Pricing
- Price placeholder mechanism
- The AJAX endpoint: get_ajax_prices
- Stock refresh
- Product-type adapters
- VAT-exempt price fix
- Front-end script
- Admin settings
- Shared framework: super-speedy-settings (submodule)
- Test rig
- Security model
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:
- 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).
- 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.
- 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:
- The cached price stays visible during the AJAX round-trip. An earlier version swapped the price for a “Loading…” string; that caused two visible reflows (price → “Loading…” → price). Now the cached price HTML is kept in place — the only change a visitor sees is cached-price → real-price in one frame. Empty prices (e.g. “Call for price”) get a
so they still reserve a line. See no layout shift. - The script is enqueued universally on the front end, with no
is_woocommerce()/is_product()gating. It no-ops if it finds no placeholders. This is deliberate: product loops appear viashortcodes, block themes, and theme homepage grids that the conditional tags miss. (A real bug — prices never loading on shortcode pages — drove this.)-

Super Speedy Ajax Prices
Price range: €99.00 through €199.00 Select options This product has multiple variants. The options may be chosen on the product page -

Super Speedy Chat
Price range: €0.00 through €299.00 Select options This product has multiple variants. The options may be chosen on the product page -

Super Speedy Pack
Read more -

Scalability Pro
Price range: €79.00 through €799.00 Select options This product has multiple variants. The options may be chosen on the product page -

Super Speedy Search
Price range: €79.00 through €799.00 Select options This product has multiple variants. The options may be chosen on the product page -

Super Speedy Filters
Price range: €79.00 through €799.00 Select options This product has multiple variants. The options may be chosen on the product page -

Super Speedy Imports
Price range: €79.00 through €1,999.00 Select options This product has multiple variants. The options may be chosen on the product page -

External Images
Price range: €79.00 through €899.00 Select options This product has multiple variants. The options may be chosen on the product page -

Auto Infinite Scroll
Price range: €49.00 through €299.00 Select options This product has multiple variants. The options may be chosen on the product page -

Super Speedy Sitemaps
€0.00 Add to cart -

Faster Datafeedr Product Sets
€0.00 Add to cart -

Fullscreen Typeform
€0.00 Add to cart -

Datafeedr Merchant Research
€0.00 Add to cart
-
is_admin()istrueduringadmin-ajax.php. Code that needs to behave differently on a real admin screen vs. an AJAX request checksDOING_AJAX/REST_REQUESTexplicitly.stock_refresh_enabled()is the key example.- The published-only gate is enforced in two places — when inserting the placeholder and inside the AJAX handler — so they can never disagree, and so an unauthenticated caller can’t enumerate prices/stock of unpublished products. See the security model.
- The VAT-exempt fix also rewrites the variation-prices cache hash. Adjusting prices per tax-location without also keying the cache by location would let one visitor’s price be served to another from WooCommerce’s internal cache. The hash filter is the non-obvious half of that fix.
- Adapters are decoupled via a filter, not hard references. The core never mentions Bundles or Composite by name in its data path; those plugins’ data is injected through
ssap_stock_data_for_product. Adding support for a new product type means adding an adapter, not touching the core. - The AJAX endpoint returns only public data and changes no state, so its nonce is effectively decorative (the page is cached, so the nonce is a shared constant for logged-out visitors). The real protections are the published-only gate and the per-request product cap.
- The settings submodule is a version-guarded singleton. Multiple Super Speedy plugins each ship a copy; only the highest-versioned one becomes the live instance, so the newest framework code wins regardless of plugin load order.
Bootstrap & lifecycle
super-speedy-ajax-prices.php is intentionally thin:
- Aborts if
ABSPATHis undefined (direct-access guard). - Loads the settings submodule and calls
SuperSpeedySettings_1_0::init(...)to register this plugin with the shared framework (slug, version, file). - Defines constants:
WC_AJAX_PRICING_PATH,…_URL,…_VERSION(read from the plugin header viaget_file_data, so the header is the single source of truth for the version), plus twodefine()-overridable behaviour flags: WC_AJAX_PRICING_VERBOSE_LOGGING(defaultfalse) — gates the tax-debug logging.WC_AJAX_PRICING_TRUST_VAT_EXEMPT(defaultfalse) — see VAT-exempt fix.- On
plugins_loaded,wc_ajax_pricing_init()checksclass_exists('WooCommerce'). If WooCommerce is missing it shows an admin notice and bails; otherwise it news upWC_AJAX_Pricingand 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_url—admin-ajax.php.nonce—wp_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:
- Bail (return original) in admin.
- Bail if the product status isn’t
publish(the security gate). - Bail if
wc_ajax_pricing_disable_for_logged_in= yes and the user is logged in. - Bail if
wc_ajax_pricing_disable_with_cart= yes and the cart is non-empty. - Otherwise wrap the price:
<span class="ajax-price[ ajax-price-variable]" data-product-id="123">
<span class="ajax-price-placeholder">{cached price HTML, or if empty}</span>
</span>
ajax-price-variableis added for variable products so the JS/consumers can distinguish them.- The
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 fromget_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.
check_ajax_referer('wc-ajax-pricing-nonce', 'nonce')(see security model for why this is weak-by-design and acceptable).- Read
product_idsfrom$_POST, defensively: non-array input is coerced to an empty array (avoids a PHP 8TypeError), then IDs areabsint‘d, de-duplicated, zero-filtered, and capped atapply_filters('ssap_max_products_per_request', 200). - For each ID: load via
wc_get_product(), skip if it doesn’t exist or isn’tpublish, then collectget_price_html()(which runs WooCommerce’s full geolocation + pricing-rule stack for the current visitor) and anis_variableflag. - If stock refresh is on, also collect
get_stock_payload($product)per product. - 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()injectsajax-stock+data-product-idinto WooCommerce’s stock<p>via a singlepreg_replace.tag_loop_add_to_cart()adds a stablessap-add-to-carthook class +data-ssap-product-idto 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_Bundle—maybe_register()only hooks in ifWC_Product_Bundleexists. For bundle products it adds abundled_itemsmap (per-childproduct_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 ifWC_Product_Compositeexists. For composite products it adds acomponentsmap (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:
adjust_variation_price_for_tax_exempt()on the threewoocommerce_variation_prices_*filters. It only acts when tax is enabled, prices include tax, the product is taxable, andcustomer_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).add_tax_location_to_prices_hash()onwoocommerce_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:
collectProductIds()— gathers IDs from.ajax-price:not(.ajax-price-loaded)(and, when stock refresh is on,.ajax-stockand.ssap-add-to-cartnot-yet-loaded markers). The:not(.…-loaded)filter means a re-init only fetches freshly-appended products, not the whole page again.initAjaxPricing()— bails ifshouldDisableAjaxPricing()(cart cookie present and the disable-with-cart param is set) or if there are no IDs. Records each placeholder’s current HTML indata-original-contentfor the failure path, then callsfetchPrices().fetchPrices()— one POST toget_wc_priceswith all IDs. On success,updatePrices()and (if present)updateStock(). On error or an unsuccessful body,restoreOriginalPrices()puts the cached HTML back.updatePrices()sets each placeholder’sinnerHTMLto the returnedprice_htmland marks itajax-price-loaded; then fireswc_ajax_pricing_updatedondocument.body.updateStock()replaces tagged stock nodes and Add-to-Cart links with the server-rendered fresh markup, marking themajax-stock-loaded; then fireswc_ajax_inventory_updatedwith 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
innerHTMLis 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_cartwc_ajax_pricing_disable_for_logged_inwc_ajax_pricing_vat_exempt_fixwc_ajax_pricing_refresh_stockwc_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.
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-checkerlibrary, 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.shis the entry point: it refuses to run unlesssiteurlis a dev URL andSSAP_TEST_MODEis defined, symlinks the test mu-plugin intomu-plugins/, seeds idempotent fixtures (SKUs prefixedssap-test-), snapshots your SSAP settings and restores them on exit, and forwards extra args to Playwright.mu/ssap-test-mode.phpis a dev-only mu-plugin exposing request-time levers (?ssap_test_country=, and?ssap_test_user=gated behind bothSSAP_TEST_MODEandSSAP_ALLOW_USER_SWITCH) plus a/wp-json/ssap-test/v1/whoamiprobe. 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()andget_ajax_prices()skip any product whose status isn’tpublish, 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_idscan’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-sideinnerHTMLwrites use trusted WooCommerce-rendered HTML.