Developers

Architecture Overview

Published · Last updated

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:

  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

ComponentFile(s)Purpose
Bootstrapsuper-speedy-ajax-prices.phpDefines constants, loads the settings submodule, instantiates the main class on plugins_loaded (guarded by a WooCommerce check).
Main classincludes/class-ssap-pricing.phpThe whole runtime: registers hooks, rewrites price HTML into placeholders, serves the AJAX endpoint, and handles stock tagging.
Front-end scriptassets/js/ssap-pricing.jsCollects placeholder product IDs, fires the batched AJAX request, swaps prices/stock into the DOM, restores cached HTML on failure.
Admin settingsadmin/settings-page.phpSettings API page under the Super Speedy menu (exclusion toggles, stock refresh, loading text).
Product-type adaptersincludes/adapters/class-ssap-adapter-bundle.php, …-composite.phpOptional, 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 frameworksuper-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() is true during admin-ajax.php. Code that needs to behave differently on a real admin screen vs. an AJAX request checks DOING_AJAX/REST_REQUEST explicitly. 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_request so 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:

  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: SSAP_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 one define()-overridable behaviour flag:
    • SSAP_VERBOSE_LOGGING (default false) — gates the diagnostic logging of the paths where the plugin declines to tag a region.
  4. On plugins_loaded, ssap_init() checks class_exists('WooCommerce'). If WooCommerce is missing it shows an admin notice and bails; otherwise it news up SSAP_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: SSAP_Pricing

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

Hooks registered

HookMethodWhen active
wp_enqueue_scriptsregister_scriptsAlways (front end)
woocommerce_get_price_htmlreplace_price_with_placeholderAlways
woocommerce_get_stock_htmltag_stock_htmlOnly if stock refresh enabled
woocommerce_loop_add_to_cart_linktag_loop_add_to_cartOnly if stock refresh enabled
wp_ajax_ssap_get_prices / wp_ajax_nopriv_ssap_get_pricesget_ajax_pricesAlways
woocommerce_get_variation_prices_hashadd_user_context_to_prices_hashAlways

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, and transport for 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:

  1. Bail (return original) in admin.
  2. Bail if the product status isn’t publish (the security gate).
  3. Bail if ssap_disable_for_logged_in = yes and the user is logged in.
  4. Bail if ssap_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. No nonce check — none is sent (see security model for why that is both necessary and safe).
  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 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.

Did you know?

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() 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, 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.

Why a <template> and not a hidden <div>

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 make document.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_form or .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.

Did you know?

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.

Note

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 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().

Best practice

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:

  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 ssap_get_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 ssap_prices_updated on document.body.
  5. updateStock() compares the state each element was rendered in against the state the server reports. When they agree it marks the element ajax-stock-loaded and 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 fires ssap_stock_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).

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 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_cart
  • ssap_disable_for_logged_in
  • ssap_refresh_stock
  • ssap_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 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.

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.
  • 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-side innerHTML writes use trusted WooCommerce-rendered HTML.
Common mistake

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.

Further reading

The constants, filters, endpoint contract, and JS events this tour describes, in reference form.
The same core idea explained without the code detail.
Extend the stock payload the way the built-in adapters do.
The WooCommerce tax configuration behind prices that look wrong in VAT-free countries.

Comments

▲
▼
1/1