Architecture Overview
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.9.1.
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-ssap-pricing.php | The whole runtime: registers hooks, rewrites price HTML into placeholders, serves the AJAX endpoint, and handles stock tagging. |
| Front-end script | assets/js/ssap-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, 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. |
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 via[products]shortcodes, block themes, and theme homepage grids that the conditional tags miss. (A real bug — prices never loading on shortcode pages — drove this.) 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 same fact has a much larger consequence: it is why the plugin no longer rebuilds Add-to-Cart markup in that request at all — see both stock states in the page.- The page ships both stock states, and the spare lives in a
<template>. Rather than fetching replacement markup when stock changes, the product page renders the in-stock and out-of-stock versions of the Add-to-Cart region while every plugin is loaded, and the browser swaps them. It is a swap, not a replacement — the state leaving the screen is parked back in the template, so it can switch as often as it needs to. - 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 variation-prices cache hash is keyed by user context. WooCommerce caches a variable product’s price range under a hash; a membership or B2B plugin pricing differently per user would otherwise have one user’s range served to another from that cache. The plugin adds the user’s ID and roles to the hash, and exposes
ssap_user_context_for_requestso a pricing plugin can add its own tier or group. WooCommerce keys the same cache by tax location itself, so tax needs nothing from us. - 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 carries no nonce at all, deliberately: a nonce is per-user and expires, but this markup is baked into a full-page cache and replayed to other visitors, so a baked nonce is stale or belongs to somebody else and WordPress would reject the request. The endpoint is safe without one because it returns only public data and changes no state; 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:
SSAP_PATH,…_URL,…_VERSION(read from the plugin header viaget_file_data, so the header is the single source of truth for the version), plus onedefine()-overridable behaviour flag:SSAP_VERBOSE_LOGGING(defaultfalse) — gates the diagnostic logging of the paths where the plugin declines to tag a region.
- On
plugins_loaded,ssap_init()checksclass_exists('WooCommerce'). If WooCommerce is missing it shows an admin notice and bails; otherwise it news upSSAP_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: SSAP_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_ssap_get_prices / wp_ajax_nopriv_ssap_get_prices | get_ajax_prices | Always |
woocommerce_get_variation_prices_hash | add_user_context_to_prices_hash | Always |
Script registration & the localized params
register_scripts() skips the admin area, then registers and enqueues ssap-pricing.js, localizing a ssap_params object containing:
ajax_url—admin-ajax.php.rest_url/ultra_url— the other two transports’ endpoints, andtransportfor which one this store uses. There is deliberately no nonce in this object; see the security model below.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
ssap_disable_for_logged_in= yes and the user is logged in. - Bail if
ssap_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.
- No nonce check — none is sent (see security model for why that is both necessary and safe).
- 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 ssap_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.
is_admin() returns true during an admin-ajax.php request, not only on a wp-admin screen. Any code that must behave differently on a real admin screen versus an AJAX call has to test DOING_AJAX or REST_REQUEST explicitly. stock_refresh_enabled() is the case where this matters most in this plugin.
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, stock_state and unchanged. It returns add_to_cart_html and single_regions only when the state has actually changed — see the next section for why that is the interesting part. The array is passed through the ssap_stock_data_for_product filter before return — the adapter extension point.
Both stock states in the page: the <template> trick
This is the most unusual thing in the plugin, and the reasoning matters more than the mechanism.
The problem: markup we did not write
Until 1.9 the Add-to-Cart area was rebuilt on the server inside the prices request and swapped into the page. That works right up until another plugin owns that area. A downloads plugin, a quote-request plugin, a subscription widget — anything that puts its own button where Add to Cart normally goes — could find its button quietly replaced with a plain WooCommerce one.
The cause was not that we swapped. It was that the markup we built to swap in was not what the page would have rendered. WordPress reports is_admin() as true throughout an admin-ajax.php request, and a great many WooCommerce plugins register their front-end behaviour behind if ( ! is_admin() ). Inside the prices request their code never runs, so their filter is not on the hook, WooCommerce hands back its own Add to Cart, and we paste it over theirs.
The same request also runs without whatever the theme’s JavaScript did to the markup after page load — Enfold’s − / + quantity buttons, for instance, which are added once at DOM-ready by a private closure that cannot be called from outside.
The fix: render both states while we still can, and park the spare
A real page render does not have either problem: every plugin is loaded, is_admin() is false, and the theme’s scripts run against the result. So the plugin now renders the add-to-cart region twice at page-render time — once for the stock state that applies, and once for the opposite one — and ships both.
The state that is not currently on screen goes into an inert <template> element next to the live one:
<div class="ssap-single-add-to-cart" data-ssap-product-id="123"
data-ssap-region="classic" data-ssap-stock="instock">
… the live region: quantity box, Add to Cart, or whatever your plugins put there …
</div>
<template class="ssap-alt-region" data-ssap-product-id="123"
data-ssap-region="classic" data-ssap-stock="outofstock">
… the same region as it would render sold out: the back-in-stock form, say …
</template>
When the batched request comes back and the stock state has changed, the browser swaps the two over. No markup is fetched from the server at all.
A hidden second copy of the region would be simpler, and it is the wrong answer. Content inside a <template> is parsed but not part of the document DOM, which buys four things at once:
- No duplicate
ids. A variation form carries<select id="pa_colour">and friends. Two live copies would makedocument.getElementById()and<label for="…">resolve to whichever came first — possibly the invisible one. Clicking a label would move focus to something nobody can see, which is an accessibility defect rather than a cosmetic one. - Nothing binds to it. WooCommerce’s variation form, and every third-party script that hooks
.variations_formor.quantity input, simply does not see it. - Nothing in it is announced by a screen reader, and no inline
<script>inside it runs until it is swapped in. - No duplicated interactive state. A fifty-variation product would otherwise ship its entire variation JSON twice.
The cost is that a theme has not decorated the parked copy at DOM-ready, so after a swap the plugin re-fires the events themes already use for exactly this purpose (updated_cart_totals by default, filterable with ssap_reinit_events). That is a fair trade, because in the common case — the state has not changed — the page behaves precisely as if this plugin were not installed: one region, rendered normally, decorated normally, untouched.
It is a swap, not a replacement
The two never get thrown away. The markup leaving the screen goes back into the <template>, so the template is always occupied by whichever state is not currently showing.
That gives one symmetrical code path rather than a “first swap” and a separate “swap back”, and it means the page can switch states as many times as it likes — which is what a future stock-polling heartbeat would need for flash sales.
The expensive half of this was already being done and thrown away. Since 1.8 the plugin rendered the opposite stock state once behind the scenes purely to capture the CSS and JavaScript a back-in-stock form would need, then discarded the HTML. 1.9 keeps that HTML instead of binning it — so shipping both states costs almost nothing extra.
The state the page was rendered in
Each tagged element carries data-ssap-stock="instock|outofstock", recording the state its HTML was built in. The browser sends that map up with the batched request as page_states, and when the shop still agrees the response says "unchanged": true and carries no markup at all.
That matters twice over. Building the region was the expensive part of the response, so the common case gets dramatically cheaper. And it is the part the server cannot render faithfully anyway — so the less often it is needed, the better.
page_states is visitor-supplied and worth being clear about: a caller who lies about it is told “unchanged” and served no markup. They learn nothing they could not learn by loading the page, and they cannot affect what any other visitor sees, because the value only ever shapes the response to the request that sent it.
The limits
Two, both deliberate:
- WooCommerce has a third stock state. On-backorder is neither of the two we render, so a product that lands there still needs a server-rendered region — with every caveat above intact, for a much rarer case.
- Shop and category pages are opt-in. The same technique works for loop Add-to-Cart buttons, but two buttons per card across a large grid is real page weight, so it sits behind a BETA toggle that is off by default. Measure it on your own shop before leaving it on.
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().
To support a new product type, add an adapter that hooks ssap_stock_data_for_product rather than editing the core. The core fires the filter and never names Bundles or Composite in its data path, so your product-type knowledge lives in one self-registering file that no-ops when its plugin is absent.
Per-user variation price caching
WooCommerce caches a variable product’s computed price range in a transient keyed by a hash of everything it believes can change that range. Anything else that changes prices per visitor has to contribute to that hash or the cache will serve one visitor’s prices to another.
add_user_context_to_prices_hash() on woocommerce_get_variation_prices_hash appends the current user’s ID and sorted roles. That covers role-priced stores on its own, and membership, B2B and wholesale plugins that price by tier or group can add their own identifier through the ssap_user_context_for_request filter, described in the Developer Reference.
Guests resolve to user ID 0 with no roles, so every logged-out visitor shares one context and therefore one cache entry — which is what keeps the cache useful for page-cached traffic.
Tax needs nothing here: WooCommerce already keys this cache by the visitor’s tax location itself.
Front-end script
assets/js/ssap-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 tossap_get_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 firesssap_prices_updatedondocument.body.updateStock()compares the state each element was rendered in against the state the server reports. When they agree it marks the elementajax-stock-loadedand touches nothing. When they differ it swaps the region with its parked<template>(falling back to server markup only if there is no template), refreshes the stock text, re-fires the theme re-init events, and firesssap_stock_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).
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 ssap-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:
ssap_disable_with_cartssap_disable_for_logged_inssap_refresh_stockssap_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
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.
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. - No nonce, by design. A nonce cannot survive in cached HTML: it is per-user and expires in roughly 12 to 24 hours, so a nonce baked into a cached page is stale or belongs to another visitor, and the request 403s. The endpoint therefore takes none, and identifies the visitor from their own login cookie instead. That is acceptable only because it 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.
Treating the prices endpoint as authenticated and hanging state-changing behaviour off it. It takes no nonce, because a nonce cannot live in cached HTML. It is safe only because it returns public, published-product data and changes nothing; keep it read-only.

Comments