# AJAX Loading 2.0 – compatibility

How AJAX Loading 2.0 deals with plugins, themes, browsers and WordPress/PHP versions. Everything here
is taken from the 2.0.0 code: `includes/class-ajax-loading-compat.php` (integration registry,
detection, environment report), `assets/src/js/frontend/compat.js` (browser-side adapters),
`includes/class-ajax-loading-exclusions.php` (built-in exclusions) and `navigation.js`
(validation). It describes what the code does; it does not claim that a given plugin version was
tested. Test results are recorded in [testing.md](testing.md).

The general rule: when the engine cannot be sure a page will work after an AJAX swap, it loads that
page normally. A normal page load is always correct.

Contents

1. [Integration modes](#1-integration-modes)
2. [Integration reference](#2-integration-reference)
3. [Interactive blocks (Interactivity API)](#3-interactive-blocks-interactivity-api)
4. [Themes](#4-themes)
5. [Multilingual sites](#5-multilingual-sites)
6. [WooCommerce](#6-woocommerce)
7. [Membership and login plugins](#7-membership-and-login-plugins)
8. [Page caching, CDNs and optimisation plugins](#8-page-caching-cdns-and-optimisation-plugins)
9. [Other navigation, transition and prefetch tools](#9-other-navigation-transition-and-prefetch-tools)
10. [Browsers](#10-browsers)
11. [WordPress and PHP versions](#11-wordpress-and-php-versions)
12. [Checking a site](#12-checking-a-site)

---

## 1. Integration modes

Each integration has a mode, set on **Settings → AJAX Loading → SEO & Integrations**:

| Mode | In the browser |
|---|---|
| `auto` | The adapter is active: it may force a normal load when the integration cannot be started again (its "bypass" check), it tells the script loader which inline data to refresh and which scripts to re-run, and it re-initialises the integration in the new content after the new page's scripts ran. |
| `exclude` | When the **new content** contains one of the integration's markers, the destination loads normally (`integration_excluded`). For `multilingual` the whole new document is checked. |
| `off` | Ignored: no bypass check, no hints, no re-initialisation. |

Notes:

- `ninja_forms`, `acf_forms` and `buddypress` only allow `exclude` and `off` (they cannot be started
  again); `auto` is treated as `exclude` for them.
- **Safe mode** forces `elementor`, `divi`, `beaver_builder` and `bricks` to `exclude` unless they
  are `off`.
- "Detected" in the admin table comes from server-side checks (class, function or constant of the
  plugin). The browser adapters do their own detection (globals of the integration or its markers in
  the new content) and only act when the integration is present, so leaving an absent integration on
  `auto` costs nothing.
- Every adapter call is wrapped in `try/catch`; a failing adapter is logged in debug mode and never
  fails the navigation. Adapters never call an integration's global start-up function a second time
  where that would re-register handlers (Elementor `init()`, Formidable `frmFrontForm.init()`).
- The `ajax_loading_integrations` filter can relabel entries, restrict their modes or add rows. The
  mode of an added row is saved from the settings form and passed to the browser in `integrations`,
  but the browser only has adapters for the keys below.

---

## 2. Integration reference

"New content" means the elements that replace the current content (the content area, and the header
or footer when they are replaced). All calls in the "auto" column are made after the new page's
scripts ran, and only when the integration's markers are in the new content unless stated otherwise.

| Key | Detected (server) | Default | `auto` in the browser | `exclude` markers | Limitations |
|---|---|---|---|---|---|
| `woocommerce` | `WooCommerce` class or `WC()`; `WC_VERSION` | auto | **Normal load** when the new content has the cart or checkout block, `form.woocommerce-checkout`, `form.checkout`, `form.woocommerce-cart-form` or `#order_review`. Re-init (jQuery, only for scripts that were loaded before the swap): `$( '.wc-tabs-wrapper, .woocommerce-tabs, #rating' ).trigger( 'init' )`; per `.woocommerce-product-gallery`: `wc-product-gallery-before-init`, `$gallery.wc_product_gallery( wc_single_product_params )`, `wc-product-gallery-after-init`; `$( '.variations_form' ).wc_variation_form()`; re-binds `change` on `.woocommerce-ordering select.orderby` to submit its form. Copies the PhotoSwipe `.pswp` root from the new page when missing. `$( document.body ).trigger( 'wc_fragment_refresh' )` when the `woocommerce_cart_hash` cookie changed or the new content has `.widget_shopping_cart_content`; `window` event `wc-blocks_store_sync_required` when the cart hash changed. Refreshes `wc-single-product-js-extra` and `wc-add-to-cart-variation-js-extra`. | `.woocommerce`, `[class*="wp-block-woocommerce-"]`, `.wc-block-grid`, `.woocommerce-product-gallery`, `form.cart`, `.woocommerce-ordering` | The blocks store sync uses an internal WooCommerce Blocks event. Gallery/variation re-init needs jQuery. See [section 6](#6-woocommerce). |
| `elementor` | `ELEMENTOR_VERSION` | auto | **Normal load** when the new content has an Elementor document and either `elementorFrontend` is not booted on the current page or the new page needs Elementor Pro (script or popup) that the current page did not load. Before the swap: `elementor/element/destroy` for atomic elements leaving the page; Pro popups of the old content are retired (`triggers.destroyTriggers()`, open modal hidden). After: merges `elementorFrontendConfig` into `elementorFrontend.config` (and `ElementorProFrontendConfig` into `elementorProFrontend.config`); with jQuery, syncs Pro popups printed outside the content and calls `elementorFrontend.documentsManager.attachDocumentClass( $( root ) )` for each `.elementor[data-elementor-id]`; without it, `elementorFrontend.elementsHandler.runReadyTrigger( el )` per `.elementor-element`; dispatches `elementor/lazyload/observe` for lazy background containers and `elementor/element/render` for atomic elements that did not render themselves. Never calls `elementorFrontend.init()`. Refreshes `elementor-frontend-js-before` and `elementor-pro-frontend-js-before`. | `.elementor[data-elementor-id]`, `[data-e-type][data-id]`, `.elementor-element` | Turn off Elementor Pro Page Transitions (the environment report warns when they appear enabled). Elementor action, lightbox and page-transition opt-out links are always excluded. |
| `divi` | Divi or Extra theme version, else `ET_BUILDER_PLUGIN_VERSION`/`ET_BUILDER_VERSION` | auto | **Normal load** when Divi 5 assets are on the current or new page, or when the new content has Divi modules but `et_pb_init_modules` is not loaded. After (jQuery): `et_pb_init_modules()`, `et_reinit_waypoint_modules()` when present, `$( window ).trigger( 'resize' )`. Refreshes `divi-custom-script-js-extra`. Per-page Divi stylesheets are swapped by the style sync. | `.et_pb_section`, `.et_pb_row`, `.et_pb_module`, `.et-l--post` | Divi 5 always loads normally (no verified script API yet). |
| `beaver_builder` | `FLBuilder` class or `FL_BUILDER_VERSION` | auto | Re-runs the page's layout script `/bb-plugin/cache/{post id}-layout(-partial).js` even if a script with that URL already ran. After (jQuery, only if `FLBuilderLayout` existed before the swap): `FLBuilderLayout.init()`. | `.fl-builder-content` | Needs jQuery. |
| `bricks` | `BRICKS_VERSION` or the Bricks theme | auto | **Normal load** when the new content has Bricks elements but `bricksRunAllFunctions` is not loaded. After: `bricksRunAllFunctions()`, then `document` event `bricks/ajax/load_page/completed`. Refreshes `bricks-scripts-js-extra`. | `[class*="brxe-"]`, `#brx-content` | – |
| `contact_form_7` | `WPCF7_VERSION` or `WPCF7` class | auto | **Normal load** when the new content has a CF7 form but `wpcf7` is not booted on the current page. After: `wpcf7.init( form )` for each `.wpcf7 > form` not yet initialised, wrapper class `no-js` → `js`, then fetches the form's validation schema (`GET {wpcf7.api.root}contact-form-7/v1/contact-forms/{id}/feedback/schema`) and stores it with `wpcf7.schemas.set()`. | `.wpcf7` | One extra same-site REST request per new form. |
| `gravity_forms` | `GFForms`/`GFCommon` classes | auto | **Normal load** when the new content has a form and the `gform` object is missing or `gform.domLoaded === false`. Before the swap it records `gform/post_render` events; after the form scripts ran (two frames + 120 ms) it fires, for forms that did not render themselves, `$( document ).trigger( 'gform_post_render', [ id, page ] )` (with jQuery) and the `document` event `gform/post_render` `{ formId, currentPage }`. Refreshes `gform_gravityforms-js-extra`. | `.gform_wrapper` | – |
| `wpforms` | `WPFORMS_VERSION` or `wpforms()` | auto | After: replaces `window.wpforms_settings` with the JSON from the new page's inline script (parsed, never evaluated); when WPForms was loaded before the swap and jQuery exists: `wpforms.ready()`. A newly loaded WPForms initialises itself. | `.wpforms-container`, `form.wpforms-form` | `ready()` path needs jQuery. |
| `fluent_forms` | `FLUENTFORM`/`FLUENTFORM_VERSION` | auto | After (jQuery, Fluent Forms loaded before the swap): `$( document ).trigger( 'ff_reinit', [ form ] )` per `form.frm-fluent-form`. | `form.frm-fluent-form`, `.fluentform` | Needs jQuery. |
| `formidable` | `FrmAppHelper`/`FrmForm` classes | auto | After: applies the honeypot-hiding CSS from Formidable's footer script as a `<style data-ajax-loading="formidable-honeypot">` (extracted as text, not executed); calls `frmRecaptcha()`, `frmHcaptcha()` or `frmTurnstile()` when the captcha library is loaded and the matching element is in the new content. Never calls `frmFrontForm.init()`. | `.frm_forms`, `.frm-show-form` | – |
| `ninja_forms` | `Ninja_Forms` | **exclude** | – (`exclude`/`off` only) | `.nf-form-cont` | Ninja Forms cannot start again after load; `off` will leave its forms dead. |
| `acf_forms` | `ACF` class or `acf_form()` | **exclude** | – (`exclude`/`off` only) | `form.acf-form`, `.acf-form` | ACF forms depend on footer data of their own page. |
| `buddypress` | `buddypress()`/`BuddyPress` | **exclude** | – (`exclude`/`off` only). With `exclude`, BuddyPress pages are also bypassed on the server, `#buddypress a` links are excluded and a destination with the `buddypress` body class loads normally. | `#buddypress`, `.buddypress-wrap`, `[data-bp-list]` | – |
| `embeds` | always | auto | `twttr.widgets.load( root )` (or `twttr.ready()`), `instgrm.Embeds.process()`, `FB.XFBML.parse( root )` when their SDK is loaded; adds a `data-secret` and `#?secret=` to WordPress post-embed iframes without one when `wp.receiveEmbedMessage` exists; re-runs TikTok's `embed.js` when the new content has TikTok embeds. | X/Twitter, Instagram, Facebook, TikTok embed markup, `iframe.wp-embedded-content` | An SDK that is not on the current page only arrives if the new page includes its script (then it runs as a new script). |
| `lazyload` | constants of known lazy-load and optimisation plugins | auto | `lazySizes.loader.checkElems()`; `update()` on `window.lazyLoadInstance`/`lazyloadInstance`/`LazyLoadInstance` (vanilla-lazyload instances). | `.lazyload`, `.lazyloading`, `img.lazy`, `iframe.lazy`, `[data-ll-status]` | Other lazy loaders need their own `wp_ajax_load_complete` listener. |
| `comment_reply` | `thread_comments` option | auto | `addComment.init()` when the new content has reply links or `#respond`. | `.comment-reply-link`, `#respond`, `#commentform` | Reply links themselves are never AJAX-navigated; `?replytocom=` URLs are excluded. |
| `post_load_event` | always | auto | `jQuery( document.body ).trigger( 'post-load' )` after every navigation (when jQuery exists). Many plugins re-initialise on this infinite-scroll convention. | (Jetpack sharing/likes/galleries/related posts, infinite scroll markup) | Needs jQuery. Handlers run on every navigation. |
| `multilingual` | WPML, Polylang, TranslatePress, Weglot, qTranslate-XT, MultilingualPress | auto | **Normal load** when `<html lang>` differs between the current and the new page (`language_changed`). | Any of `link[rel="alternate"][hreflang]`, `.wpml-ls`, `.wpml-ls-item`, `.lang-item`, `.pll-parent-menu-item`, `.trp-language-switcher` **anywhere in the new document** | In `exclude` mode almost every page of a multilingual site carries hreflang links, so nearly all navigations become normal loads. See [section 5](#5-multilingual-sites). |

Script hints from the table ("refreshes …", "re-runs …") feed the script planner: a refreshed inline
script (for example `*-js-extra` localized data) is executed again when its text changed, and a
re-run external script is executed even though a script with the same URL already ran. See
[architecture.md § Script and style policy](architecture.md#9-script-and-style-policy).

---

## 3. Interactive blocks (Interactivity API)

Blocks built with the WordPress Interactivity API (`data-wp-interactive`) cannot be started again
after a page has loaded.

- Setting **Interactive blocks** (`interactive_blocks`, Exclusions tab): `reload` (default) loads a
  destination normally when its **new content** contains `[data-wp-interactive]`
  (`interactive_content`); `replace` swaps the markup without starting the blocks (they do not react
  until the next normal load). Safe mode forces `reload`.
- Interactive blocks in a **preserved** header or footer (for example the Navigation block overlay)
  keep working because those elements are not replaced. If the header or footer is replaced (Header
  or Footer mode "Replace", or automatic replacement when only block layout classes differ), and it
  contains interactive blocks, the destination loads normally.
- When the page loaded the **Interactivity router** (`@wordpress/interactivity-router`, used for
  example by a Query Loop with enhanced pagination), the engine does not start on that page: the
  router reloads the page on Back/Forward for URLs it does not know. Links into router regions and
  links with `data-wp-on--click`/`data-wp-on-async--click` are never AJAX-navigated.
- `history.state.wpInteractivityId` is copied into every entry the engine pushes, so the
  Interactivity runtime does not reload on Back/Forward between AJAX pages.

---

## 4. Themes

**Detection** happens in the browser on every navigation (see
[architecture.md § Content detection](architecture.md#6-content-detection)). Auto mode tries the
**Content selectors** setting and `[data-ajax-loading-container]` first, then built-in candidates
that cover classic and block themes: `#content`, `.site-content`, `main#main`, `#site-content`,
`#main`, `.site-main`, `.wp-site-blocks > main`, `main#wp--skip-link--target`, `main`, `#primary`,
`.content-area`, `[role="main"]`, `#brx-content`, `#main-content` (outermost valid one wins). Headers
and footers are recognised by common ids/classes and by `header.wp-block-template-part` /
`footer.wp-block-template-part`.

| Theme type | Notes |
|---|---|
| Classic themes | The content wrapper is usually found automatically. If the header or footer markup differs between page types, those navigations load normally (`layout_mismatch`). The same applies when a section next to the content area, such as a page title hero or banner (Kadence, GeneratePress, Twenty Seventeen's featured-image header), exists on only one of the two pages; when both pages have it, it is replaced with the content as an extra region. Menus, menu panels and widget areas printed directly inside `<body>` (Twenty Twenty's mobile menu and footer widgets, GeneratePress' navigation below the header) are kept as they are when their links differ between pages, for example because of a language switcher, so the theme's menu buttons keep working; such a switcher keeps pointing to the translation of the first page until the next full page load. |
| Block themes | `.wp-site-blocks > main` and `main#wp--skip-link--target` are candidates. The `<main>` element is kept and only its children are replaced, so the skip-link target survives. When the header or footer differs only in numbered block layout classes (`wp-container-*`, `wp-elements-*`), it is replaced automatically so its styles stay correct. On WordPress before 6.8 these numbers can change from page to page; the environment report recommends setting Footer to "Replace" or updating WordPress. |
| Templates with different headers per page type | Navigations between them load normally. For example, Twenty Twenty-Two on WordPress 6.0 uses other header template parts on archives than on pages. |
| Page builders with script-driven headers (Elementor, Divi, Bricks) | Classes their scripts add at run time are ignored by the layout and header checks: Elementor `elementor-sticky*`, `elementor-section--handles-inside`, `e-lazyloaded`; Divi `et-fixed-header`, `et-animated-content`; Bricks `slide-up`, `sliding`. More generally, the engine records the server markup when it starts, so any class a script adds later, and any element a script inserts later (sticky placeholders, mobile menu copies), never counts as a difference. On a late start (JavaScript delayed until the page finished loading) it falls back to a more tolerant comparison. |

**Server side**, the environment report (`WPAJXL_Compat::check_theme()`) shows whether the active
theme is a block theme (`wp_is_block_theme()`) and suggests a selector for known themes:

| Theme (template) | Suggested selector |
|---|---|
| Any block theme | `.wp-site-blocks > main` |
| Twenty Ten – Twenty Fourteen, OceanWP, Blocksy | `#main` |
| Twenty Fifteen – Twenty Nineteen, Twenty Twenty-One, Astra, GeneratePress, Storefront, Hello Elementor, Neve | `#content` |
| Twenty Twenty | `#site-content` |
| Divi, Extra | `#main-content` |
| Bricks | `#brx-content` |

These are suggestions shown in the report; the engine still validates the chosen container on every
navigation. To make a theme work reliably, mark its content wrapper with
`data-ajax-loading-container` ([developer-guide.md, recipe 8.7](developer-guide.md#87-make-a-theme-compatible)).

---

## 5. Multilingual sites

With the `multilingual` integration on `auto` (default):

- A destination whose `<html lang>` differs from the current page loads normally
  (`language_changed`), so menus, cookies and translations always come from a real page load. This
  check runs both in the engine's validation and in the adapter.
- Language switcher links are never AJAX-navigated: `.wpml-ls a`, `.wpml-ls-item a`, `.lang-item a`,
  `.pll-parent-menu-item a`, `.trp-language-switcher a`, `.menu-item-object-language_switcher a`,
  `a[hreflang]`, `a[lang]`.
- Within one language, `hreflang` alternates, canonical and `<html lang/dir>` are kept in sync by the
  metadata sync.

`exclude` turns every navigation to a page carrying hreflang links or a switcher into a normal load.
`off` disables both the language check and the switcher exclusions (the environment report warns
about this when a multilingual plugin is active).

Detected plugins (server side): WPML (`ICL_SITEPRESS_VERSION`), Polylang (`POLYLANG_VERSION`),
TranslatePress (`TRP_PLUGIN_VERSION`), Weglot (`WEGLOT_VERSION`), qTranslate-XT (`QTX_VERSION`),
MultilingualPress. Detection only affects the admin report; the browser check uses `<html lang>`.

---

## 6. WooCommerce

Two independent layers:

**Exclusions** (setting **Protect WooCommerce pages**, `exclude_woocommerce`, default on):

- Server bypass (marker `bypass`, no engine): cart, checkout, My Account and every endpoint
  (`is_cart()`, `is_checkout()`, `is_account_page()`, `is_wc_endpoint_url()`), and singular pages
  containing the cart/checkout blocks or the `woocommerce_cart`/`woocommerce_checkout`/
  `woocommerce_my_account` shortcodes.
- URL patterns: the cart, checkout and account URLs read from WooCommerce (and everything below
  them), `wc-api`, `wc-ajax=`, and GET actions `add-to-cart=`, `added-to-cart=`, `remove_item=`,
  `undo_item=`, `removed_item=`, `remove_coupon=`, `apply_coupon=`, `order_again=`, `cancel_order=`,
  `pay_for_order=`, `download_file=`, `copy-address=`, `wc-resend-set-password`, `reset-token=`,
  `show-reset-form=`, `add_to_wishlist=`, `remove_from_wishlist=`, `?key=`, `&key=`.
- Link selectors: `a.ajax_add_to_cart`, `a.add_to_cart_button[href*="add-to-cart="]`,
  `.remove_from_cart_button`, `.woocommerce-cart-form a.remove`, `.restore-item`,
  `.woocommerce-remove-coupon`, the My Account logout link. WooCommerce's own AJAX add-to-cart keeps
  working because the engine never intercepts these links.
- Destination body classes `woocommerce-cart`, `woocommerce-checkout`, `woocommerce-account`,
  `woocommerce-order-received`, `woocommerce-order-pay` → normal load (`bypass`).

With the switch off none of these rules apply: the browser receives `exclusions.woocommerce: false`
and stops checking the body classes too. The adapter (`auto`) still loads a destination whose new
content contains the cart or checkout normally.

**Adapter** (`woocommerce` integration, see the table): product galleries, variation forms, tabs,
sorting, PhotoSwipe root, cart fragments.

**Cart fragments and personalisation**: when the cart hash cookie changed during the navigation, or
the new content contains a mini-cart widget, `wc_fragment_refresh` is triggered (classic cart
fragments) and the WooCommerce Blocks store is asked to sync. A visitor with a WooCommerce session or
cart cookies (`woocommerce_items_in_cart`, `woocommerce_cart_hash`, `wp_woocommerce_session_`) is
treated as personalised: their pages are never cached by the engine and never prefetched.

The product search form (`form.woocommerce-product-search`) is handled like the WordPress search
form when **AJAX search forms** is on.

---

## 7. Membership and login plugins

Setting **Protect login and membership pages** (`exclude_membership`, default on):

- Server bypass: the configured core pages of Paid Memberships Pro, Ultimate Member and MemberPress,
  MemberPress product and group pages, BuddyPress register and activation pages.
- URL patterns: login, registration and lost-password URLs (custom URLs from login filters are only
  published to logged-in users, so a login-hiding plugin's secret slug never appears in public page
  source), `/login/`, `/logout/`, `/register/`, `/lostpassword/`, `/resetpass/`, `/password-reset/`
  under the home path, the plugins' page URLs, `pmpro_level=`, Ultimate Member action arguments.
- Link selectors: `.wp-block-loginout a`, `a[href*="action=logout"]`, `.bp-login-widget a`.
- Destination body classes `pmpro-checkout`, `pmpro-billing`, `pmpro-cancel` → normal load
  (`bypass`). With the switch off (`exclusions.membership: false` in the browser) none of these rules
  apply.

Independently of this setting, `wp-login.php`, `wp-signup.php`, `wp-activate.php` and `wp-admin`
are never AJAX-navigated.

---

## 8. Page caching, CDNs and optimisation plugins

- **Page caches** store the marker with the page. The marker carries the plugin version: a cached
  page from another plugin version loads normally (`asset_changed`). The client-cache version
  (`data-cache`) also lives in cached HTML, so purge the page cache after changing settings or using
  **Clear navigation cache**. `data-personalized` is computed per request; pages served from a cache
  for anonymous visitors are not personalised. When a page cache is detected (`WP_CACHE` or a known
  page caching plugin, `WPAJXL_Compat::has_page_cache()`), the admin notices after quick actions and
  after saving the settings remind you to purge it.
- **CDNs**: HTML pages must come from the same origin as the page showing (same scheme, host and
  port) and inside the WordPress home path. Scripts, styles and images may come from any origin; they
  are loaded by URL like on a normal page load. A site reachable over both HTTP and HTTPS treats the
  other scheme as another origin (the report warns).
- **Multisite**: every site prints its own site id in the marker (`data-site`). On a subdirectory
  network, where all sites share one origin, a link to a page of another site is fetched and then
  loaded normally (`bypass`, never counted by the circuit breaker), and that site's cache version
  never touches this site's in-memory cache. Subdomain and domain-mapped sites are other origins and
  are never fetched.
- **Content Security Policy** (security plugins, host or CDN headers): with a nonce-based policy the
  engine only runs the scripts of a fetched page that this page's own policy (response header or
  `<meta http-equiv>`) allows; scripts allowed only by a hash are not run after an AJAX navigation.
  The inline configuration and the `reload_helper` snippet must be allowed (for example with a nonce
  through WordPress' script attribute filters). See
  [architecture.md § Script and style policy](architecture.md#9-script-and-style-policy).
- **JavaScript delay/defer/combine features** (WP Rocket, Perfmatters, Autoptimize, FlyingPress,
  LiteSpeed Cache, NitroPack …; detected for the report): the environment report advises excluding
  the engine (`ajax-loading-WPajaxLoad`, `assets/dist/frontend.min.js`) and its inline configuration
  from these features. Two code facts to keep in mind:
  - scripts whose `type` was rewritten to a non-JavaScript value (as "delay JS" features do) are
    never executed by the engine after an AJAX navigation;
  - a combined bundle whose URL changes from page to page counts as a new external script and runs
    again, including any library code inside it.
- **Minified HTML** is fine: head tags are matched by selector, never by comments.
- **Speculative loading** (WordPress 6.8+) is turned off on pages where the engine runs (setting
  **Turn off WordPress speculative loading**, filter `ajax_loading_disable_core_speculative_loading`)
  so links are not requested twice; pages that load normally keep it.

---

## 9. Other navigation, transition and prefetch tools

- The environment report warns when another AJAX navigation or page-transition plugin is active
  (folder names containing `pjax`, `swup`, `barba`, `turbolinks`, `ajax-page-load(er)`,
  `ajaxify-wordpress-site`, `ajax-navigation`, `page-transition`, `advanced-ajax-page-loader`). Run
  only one navigation engine.
- Elementor Pro Page Transitions also intercept clicks; the report warns when they appear enabled.
- Prefetch plugins (`instant-page`, `flying-pages`, `quicklink`, `instantclick`) work alongside the
  engine as long as the AJAX Loading prefetch option is off; with both on, pages may be requested
  twice (the report warns).
- Scripts that call `history.pushState`/`replaceState` themselves (filters, infinite scroll) keep
  working: the engine observes those calls without changing them and treats their entries as part of
  the page that pushed them.

---

## 10. Browsers

The bundle is ES2018 JavaScript. The engine starts only when `isSupportedBrowser()`
(`settings.js`) finds `fetch`, `AbortController`, `Promise`, `URL`, `DOMParser`, `TextDecoder`,
`Map`, `Set`, `WeakMap`, `history.pushState`/`replaceState`, `Element.prototype.closest`, `matches`
and `replaceChildren`, and a constructible `CustomEvent`. Everywhere else links, forms and
Back/Forward keep their normal behaviour, and `window.AjaxLoading.navigate()` uses
`location.assign()`.

`Element.replaceChildren` is the newest of these APIs. According to MDN's browser-compat-data it is
available from **Chrome and Edge 86, Firefox 78 and Safari 14 (iOS 14)**; older versions keep normal
navigation.

Optional features degrade silently:

| Feature | Without it |
|---|---|
| `IntersectionObserver` | No viewport prefetching. |
| `requestIdleCallback` | Viewport prefetch scans use a timer. |
| Pointer events | Hover prefetching uses mouse events. |
| `navigator.connection` | Network-aware prefetching cannot see Save-Data or slow connections and allows prefetching. |
| `navigator.sendBeacon` | Error reports use `fetch` with `keepalive`. |
| `fetch` `priority` | Ignored. |
| `history.scrollRestoration` | The browser may also restore scroll positions. |

With JavaScript disabled nothing changes: the plugin only adds a script and a stylesheet.

---

## 11. WordPress and PHP versions

The plugin header declares **Requires at least: 6.0** and **Requires PHP: 7.4**; the readme declares
**Tested up to: 7.1**. The environment report shows an error below WordPress 6.0 or PHP 7.4.

Version-dependent behaviour in the code:

| Feature | Version | Behaviour on older versions |
|---|---|---|
| Script loading strategies (`defer`) | WordPress 6.3 | The engine prints in the footer without `defer`; it still starts on `DOMContentLoaded`, and records the server markup and inline styles the moment parsing ends, before jQuery ready handlers change the page. |
| Stable block layout class names | WordPress 6.8 | Headers/footers whose layout classes are renumbered per page are replaced automatically; the report suggests Footer "Replace". |
| Speculative loading | WordPress 6.8 | Nothing to turn off. |
| Interactivity API and router | WordPress 6.5 (`wpInteractivityId` history state: 7.1) | Not present; the related checks never trigger. |

**Automated matrix** (`tests/matrix/run-matrix.mjs`, `npm run test:matrix`): the PHP tests and the
smoke end-to-end tests run on these combinations:

| WordPress | PHP | Theme |
|---|---|---|
| 6.0 (oldest supported) | 7.4 | classic |
| latest | 8.3 | block |
| latest | 8.5 | block |

These combinations are covered by the automated matrix; the results are recorded in
[testing.md](testing.md).

---

## 12. Checking a site

**Settings → AJAX Loading → Tools & Diagnostics** runs:

- an environment report (`GET /wp-json/ajax-loading/v1/diagnostics`): versions, plugin status,
  pause, visibility, safe mode, front-end files, permalinks, home/site URL, HTTPS, `SCRIPT_DEBUG`,
  theme, block layout classes, caching and optimisation plugins, other navigation and prefetch
  plugins, speculative loading, detected integrations and their modes, Elementor Pro Page
  Transitions, Divi 5, excluded plugins, multilingual plugins, interactive blocks, Interactivity
  router usage, the legacy jQuery setting, the PHP DOM extension (informational, not required);
- a navigation test between two URLs and a content-selector check on sample pages, using the same
  link rules and content detection as the front end. The navigation test also fails when the
  destination's new content contains an integration set to `exclude`, as the front end would.

For a page that does not AJAX-load, turn on **Debug logging** and watch the browser console: every
fallback logs its reason code (for example `layout_mismatch`, `integration_excluded`), and
**Record navigation errors** keeps the last 50 failures on the Tools tab.
