# AJAX Loading 2.0 – developer guide

This guide is for theme and plugin developers who want their code to work with AJAX Loading, and for
the maintainer. It covers the browser events, the `window.AjaxLoading` API, the HTML attributes the
engine reads, the PHP hooks, the REST endpoints and the configuration printed into the page. Every
name and signature below is taken from the 2.0.0 source (`assets/src/js/frontend/`, `includes/`).

How the engine works internally is described in [architecture.md](architecture.md). Integration
behaviour (WooCommerce, page builders, forms …) is in [compatibility.md](compatibility.md).

Contents

1. [The one thing to know](#1-the-one-thing-to-know)
2. [Lifecycle events](#2-lifecycle-events)
3. [The `window.AjaxLoading` API](#3-the-windowajaxloading-api)
4. [HTML attributes and CSS hooks](#4-html-attributes-and-css-hooks)
5. [PHP hooks](#5-php-hooks)
6. [REST endpoints](#6-rest-endpoints)
7. [Page configuration, marker and legacy globals](#7-page-configuration-marker-and-legacy-globals)
8. [Recipes](#8-recipes)
9. [Building from source](#9-building-from-source)

---

## 1. The one thing to know

After an AJAX navigation the browser does **not** load a new document. Only the content area (and,
when configured, the header or footer) is swapped. Code that runs once on `DOMContentLoaded` or
`load` does not run again for the new content. Re-initialise per-page widgets on the
`wp_ajax_load_complete` event:

```js
document.addEventListener( 'wp_ajax_load_complete', function ( event ) {
	// event.detail.container is the new content element.
	myTheme.init( event.detail.container );
} );
```

What the engine already does for you:

- Scripts inside the new content run, and scripts that are new on the destination page run once
  (see [architecture.md § Script and style policy](architecture.md#9-script-and-style-policy)).
- Built-in adapters re-initialise WooCommerce, Elementor, Divi, Beaver Builder, Bricks, several form
  plugins, embeds, lazy loaders and threaded comments ([compatibility.md](compatibility.md)).
- When anything looks doubtful (unknown layout, unsupported plugin, error) the engine falls back to a
  normal page load. Normal navigation is always the safe default.

---

## 2. Lifecycle events

Source: `assets/src/js/frontend/events.js` (dispatch) and `navigation.js` (when each event fires).

Every event is a `CustomEvent` dispatched on **`document`** with `bubbles: true` (so `window`
listeners receive it too). The `detail` object is a **frozen** snapshot: you cannot change it, and
arrays in it are frozen copies.

### 2.1 Event list

| Event | Fires | Cancelable | `detail` |
|---|---|---|---|
| `wp_ajax_load_ready` | Once per real page load, after the engine started (DOM ready). Not fired when the engine does not start on the page. | No | `{ version, url }` |
| `wp_ajax_load_before` | A navigation was accepted, before any loading UI or request. | **Yes** | Navigation detail |
| `wp_ajax_load_start` | Loading UI and leave transition started; the request is about to be sent. | No | Navigation detail |
| `wp_ajax_load_before_replace` | The new page was fetched, parsed, validated and its stylesheets loaded; nothing on screen has changed yet. | **Yes** | Navigation detail + `newDocument` |
| `wp_ajax_load_replaced` | Title, history entry, head metadata and content were swapped and the scroll position set. The new page's scripts have **not** run yet. | No | Navigation detail + `newDocument` |
| `wp_ajax_load_complete` | Scripts ran, integrations re-initialised, transition finished, focus moved, analytics sent. Exactly once per successful navigation. Never on the initial page load. | No | Navigation detail |
| `wp_ajax_load_error` | The navigation failed. A normal page load follows immediately. | No | Navigation detail with `error` |
| `wp_ajax_load_cancel` | The navigation stopped: a newer one superseded it, the page was hidden, a listener prevented it, or the browser aborted the request. | No | Navigation detail with `reason` |
| `wp_ajax_load_popstate` | Back/Forward to another page of this document's history, right before the popstate navigation starts. | No | `{ type: 'popstate', url, previousUrl, direction }` |

Order for a successful navigation:

```text
before → start → before_replace → replaced → complete
```

A navigation that is replaced by a newer click emits `wp_ajax_load_cancel` (reason `superseded`)
and then the new navigation's `wp_ajax_load_before`. A failed navigation emits `wp_ajax_load_error`
instead of the remaining events.

### 2.2 What cancelling does

| You call `event.preventDefault()` in | Result |
|---|---|
| `wp_ajax_load_before` on a **link click** | The engine leaves the click alone and the browser follows the link with a normal page load. `wp_ajax_load_cancel` (reason `prevented`) is emitted. |
| `wp_ajax_load_before` for Back/Forward, a form or `AjaxLoading.navigate()` | A normal page load of the URL. `wp_ajax_load_cancel` (reason `prevented`). |
| `wp_ajax_load_before_replace` | A normal page load of `detail.url` (the final URL). `wp_ajax_load_cancel` (reason `prevented`). |

Cancelling never keeps the visitor on the current page. To stop a click completely, call
`preventDefault()` in your own click handler: the engine listens on `window` in the bubble phase,
runs after every element and document handler, and ignores clicks that were already prevented.

Legacy global callbacks (see 2.5) cannot cancel; their return value is ignored.

### 2.3 Navigation detail fields

| Field | Type | Meaning |
|---|---|---|
| `id` | number | Sequence number of the navigation within this page lifetime. |
| `type` | string | `'link'`, `'popstate'` (Back/Forward), `'form'` (GET search/filter form) or `'programmatic'` (`AjaxLoading.navigate()` / `reload()`). |
| `url` | string | Requested URL in `before`/`start`; from then on the final URL after redirects, with the requested `#fragment` kept. |
| `requestedUrl` | string | The URL that was requested. |
| `previousUrl` | string | URL of the page that was showing when the navigation started. |
| `trigger` | Element \| null | Clicked `<a>`/`<area>`, submitted `<form>`, or the element passed to `navigate()`. |
| `direction` | string \| null | `'forward'` for a new history entry, `null` for a replaced entry, `'back'`/`'forward'`/`null` for popstate. |
| `containers` | Element[] | Live elements whose content was replaced (content first, then header, then footer). Empty before `replaced`. |
| `container` | Element \| null | `containers[0]`, the main content element (`document.body` in body mode). |
| `status` | number | HTTP status of the response, `0` before it arrives. |
| `redirected` | boolean | Whether the response came from a redirect to another page. |
| `cacheStatus` | string | `'none'` before the response, then `'miss'` (network), `'hit'` (page cache) or `'prefetch'` (prefetched response). |
| `startTime` | number | `performance.now()` when the navigation was created. |
| `duration` | number | Milliseconds; set on `complete`, `error` and `cancel`. |
| `title` | string | New `document.title`; empty before `replaced`. |
| `error` | object \| null | `{ code, message }` on `error`, otherwise `null`. Codes are listed in [architecture.md § Failure handling](architecture.md#12-failure-handling). |
| `fallback` | boolean | `true` on `error` (a normal page load follows), otherwise `false`. |
| `reason` | string | On `cancel`: `'superseded'`, `'prevented'`, `'aborted'`, `'pagehide'` or `'destroyed'`. Otherwise `''`. |
| `newDocument` | Document | Only in `before_replace` and `replaced`: the parsed, inert destination document. Read it, do not insert its nodes yourself. |

### 2.4 Four ways to listen

All four receive the same frozen detail.

```js
// 1. DOM event (recommended).
document.addEventListener( 'wp_ajax_load_complete', function ( event ) {
	console.log( event.detail.url );
} );

// 2. AjaxLoading.on(): callback( detail, event ); accepts short names; returns an unsubscribe function.
var off = window.AjaxLoading.on( 'complete', function ( detail, event ) {
	console.log( detail.title );
} );
off(); // or AjaxLoading.off( 'complete', sameFunction )

// 3. jQuery (only when the page has jQuery): handler( event, detail ).
jQuery( document ).on( 'wp_ajax_load_complete', function ( event, detail ) {
	console.log( detail.container );
} );

// 4. Legacy 1.x global function (see 2.5).
function wp_ajax_load_complete( detail ) {
	console.log( 'loaded', detail && detail.url );
}
```

Notes:

- `AjaxLoading.on()` exists as soon as the bundle has run (deferred, in the footer), even before the
  engine starts. Exceptions thrown by `on()` callbacks are caught and logged with the
  `[AJAX Loading]` prefix.
- The jQuery mirror uses a real `jQuery.Event`, so `event.preventDefault()` in a jQuery handler also
  cancels `wp_ajax_load_before` and `wp_ajax_load_before_replace`. jQuery handlers run exactly once
  per event (the engine stops jQuery from also handling the native event).
- Names accepted by `AjaxLoading.on()`/`off()`: the full name (`wp_ajax_load_complete`), or the
  short key in any case (`complete`, `COMPLETE`, `before_replace`).

### 2.5 Legacy global callbacks (1.x compatibility)

For every event, the engine looks up a global function with **exactly the event name** and calls it
with the detail as its only argument and `this` set to `window`.

| Global function | Called |
|---|---|
| `wp_ajax_load_complete` | **Asynchronously**: in a `setTimeout` after the `wp_ajax_load_complete` DOM event, delayed by `legacyCallbackDelay` ms (default `0`, filter `ajax_loading_legacy_callback_delay`, 0–10000). Once per successful navigation, never on the initial page load. |
| `wp_ajax_load_ready`, `wp_ajax_load_before`, `wp_ajax_load_start`, `wp_ajax_load_before_replace`, `wp_ajax_load_replaced`, `wp_ajax_load_error`, `wp_ajax_load_cancel`, `wp_ajax_load_popstate` | Synchronously, right after the DOM event and the jQuery mirror. |

The lookup happens **at call time** and works with:

- function declarations in a classic script: `function wp_ajax_load_complete() {}`
- window properties: `window.wp_ajax_load_complete = function () {}` or the 1.x readme form
  `wp_ajax_load_complete = function () {}`
- top-level `let`/`const` in a classic script: `const wp_ajax_load_complete = () => {};`
  (these are not properties of `window`; the bundle reads them with a bare identifier).

Anything that is not a function (for example an element with `id="wp_ajax_load_complete"`) is
ignored. Errors thrown by a legacy callback are caught and logged.

Differences from 1.x: 1.x called `wp_ajax_load_complete()` about 1000 ms after the swap and without
arguments; 2.0 calls it right after the navigation completed (delay 0 by default) and passes the
detail. Old callbacks that ignore arguments keep working. Restore the old delay with the
`ajax_loading_legacy_callback_delay` filter if a callback depends on it.

---

## 3. The `window.AjaxLoading` API

Source: `assets/src/js/frontend/index.js`. The object is created when the bundle runs (deferred
footer script). The engine itself starts on `DOMContentLoaded`. When the engine does not start
(see [architecture.md § Engine start-up](architecture.md#3-engine-start-up)), the API still exists
and every navigation method falls back to normal browser navigation.

| Member | Signature | Description |
|---|---|---|
| `version` | string | Plugin version from the configuration (`'2.0.0'`). |
| `events` | object (frozen) | Event names: `{ READY, BEFORE, START, BEFORE_REPLACE, REPLACED, COMPLETE, ERROR, CANCEL, POPSTATE }`. |
| `settings` | getter → object \| null | The normalised, frozen settings the engine uses. `null` before `DOMContentLoaded`, when the configuration is missing or the browser is unsupported. |
| `navigate(url, opts?)` | `(string, { replace?: boolean, trigger?: Element }) → Promise<boolean>` | Navigate like a link click. Resolves `true` when the page was AJAX-loaded, `false` when the engine fell back to a normal load (or is not running, in which case `location.assign`/`location.replace` is used). `replace: true` replaces the current history entry, also when the page is loaded normally instead (`location.replace`). |
| `prefetch(url)` | `(string) → Promise<boolean>` | Fetch a page ahead of time. Resolves `false` unless prefetching is enabled in the settings and every prefetch rule passes (not logged in, not excluded, not personalised, network allows it). |
| `reload()` | `() → Promise<boolean>` | Re-render the current page through AJAX, bypassing the in-memory cache and revalidating the HTTP cache. The history entry is replaced and a `#fragment` in the address is kept (the page scrolls to it and focus moves there, as after a link to it). Falls back to `location.reload()` when the engine is not running. |
| `clearCache()` | `() → void` | Empty the in-memory page cache of this tab. |
| `on(name, fn)` | `(string, (detail, event) => void) → () => void` | Subscribe; returns an unsubscribe function. |
| `off(name, fn)` | `(string, Function) → void` | Unsubscribe a callback added with `on()`. |
| `isEnabled()` | `() → boolean` | `true` while the engine is handling navigations (started, not disabled, not auto-disabled by the circuit breaker, enabled for this device type, not in the middle of a full page load). |
| `disable()` | `() → void` | Stop handling navigations: links and forms load normally, Back/Forward to AJAX entries does a full load, prefetching pauses. Can be called before the engine starts. |
| `enable()` | `() → void` | Undo `disable()`. |

Examples:

```js
// Navigate from a custom widget, with a safe fallback when the plugin is absent.
if ( window.AjaxLoading ) {
	window.AjaxLoading.navigate( '/contact/' ).then( function ( ajax ) {
		console.log( ajax ? 'AJAX navigation' : 'normal page load' );
	} );
} else {
	window.location.assign( '/contact/' );
}

// Replace the current entry (e.g. after changing a filter).
window.AjaxLoading.navigate( '/shop/?orderby=price', { replace: true } );

// Warm up a likely next page (only when prefetching is enabled in the settings).
window.AjaxLoading.prefetch( '/pricing/' );

// Pause AJAX navigation while a modal with its own links is open.
window.AjaxLoading.disable();
// … later
window.AjaxLoading.enable();

// Read a setting.
var settings = window.AjaxLoading.settings;
if ( settings && settings.cache.enabled ) { /* … */ }
```

Notes:

- `navigate()` applies the same URL rules as link clicks (same origin, inside the WordPress home
  path, not excluded). A URL that fails them is opened with a normal page load and the promise
  resolves `false`.
- `navigate()` to the page that is showing, with a `#fragment`, behaves like an in-page anchor
  (`location.assign`, the browser scrolls).
- Calling `navigate()` again for the URL that is already loading returns the running navigation's
  promise instead of starting a second request.

---

## 4. HTML attributes and CSS hooks

### 4.1 Links

A link is handled only when every rule passes (`links.js` `checkLink`). The engine never touches
clicks with a modifier key (Ctrl, Meta, Shift, Alt), a non-primary button, or a click another script
already prevented.

| Markup | Effect |
|---|---|
| `data-no-ajax` on the link or any ancestor | Normal page load. |
| `class="no-ajaxy"` (1.x) or `class="no-ajax"` on the link or an ancestor | Normal page load. |
| `data-ajax-loading="false"` on the link or an ancestor | Normal page load. |
| `target` other than `_self` (or `_top`/`_parent` inside a frame), including `<base target>` | Left to the browser. |
| `download` attribute | Left to the browser. |
| `rel="external"` | Left to the browser. |
| Inside `#wpadminbar` | Left to the browser. |
| Matches an exclusion selector (settings, `ajax_loading_exclude_selectors`, `reload_helper.ids`) | Normal page load. |

URL rules also send these to a normal page load: other origins, URLs outside the WordPress home
path, same-page `#fragment` links (the browser scrolls), `wp-admin`, login/signup/activation
pages, `wp-content`/`wp-includes` files, non-HTML file extensions (`.pdf`, `.jpg`, `.zip` …), `.php`
files other than `index.php`, any URL with a query parameter whose name contains `nonce`, and every
URL exclusion pattern (built-in, WooCommerce, membership, settings, filters).

`<a>` and `<area>` are supported, including SVG links (`href` or `xlink:href`) and links inside open
shadow roots.

### 4.2 Prefetch opt-outs

When prefetching is enabled, a link is never prefetched when it (or an ancestor) has
`class="no-prefetch"`, `data-no-prefetch` or `data-ajax-loading-prefetch="false"`, or when it has
`rel="nofollow"` (`prefetch.js`). The link can still be AJAX-navigated.

### 4.3 Forms

Only `GET` forms are handled (`forms.js`, `links.js` `checkForm`). A form is handled when it is a
search form (`form[role="search"]`, `.search-form`, `.searchform`, `#searchform`, the Search block,
the WooCommerce product search, the Search widget) and the "AJAX search forms" setting is on, or
when it matches one of the "Additional GET forms" selectors in the settings.

There is no form-specific attribute. A form loads normally when:

- it or its submit button carries one of the link opt-outs (`data-no-ajax`, `.no-ajaxy`, `.no-ajax`,
  `data-ajax-loading="false"`) or matches an exclusion selector;
- it uses `method="post"` (or the submitter has `formmethod="post"`);
- it has a `target` (or the submitter has `formtarget`) that opens another window;
- it contains an `<input type="file">`;
- the resulting URL fails the URL rules above.

### 4.4 Content container

| Markup | Effect |
|---|---|
| `data-ajax-loading-container` | Marks the element whose children are replaced. It is the first candidate of auto-detection and wins over the built-in candidates when it exists **exactly once** on both pages and passes the layout check. Put it on the same element type, at the same nesting level, on every template. |

### 4.5 Scripts and stylesheets

| Markup | Effect |
|---|---|
| `<script data-ajax-loading-skip>` | Never executed by the engine after an AJAX navigation (it still runs on a normal page load). |
| `<script data-no-ajax>` | Same as `data-ajax-loading-skip`. |
| `<script id="ajax-loading-…">` | Reserved for the plugin's own scripts; never executed by the engine. |
| `<script nomodule>` | Never executed by the engine (the browser supports modules). |
| `<script type="…">` with a non-JavaScript type (`application/ld+json`, `text/template`, `text/plain`, `importmap`, `speculationrules`, an optimiser's custom type …) | Never executed. |
| `data-ajax-loading-track="reload"` on any element (usually a theme `<link>` or `<script>` in the head) | When the tracked elements differ between the current page and the destination (for example after a theme update changed `?ver=`), the destination loads normally. |
| `<link rel="stylesheet" id="…-css" data-ajax-loading-persist>` / `<style id="…-styles" data-ajax-loading-persist>` / `<style data-ajax-loading-persist>` (id-less) | Never removed by the stale-stylesheet clean-up after a navigation. Id-less styles of the previous page are otherwise removed when the new page does not print them. |

### 4.6 CSS hooks

| Hook | When |
|---|---|
| `html.ajxl-navigating` | While a navigation is in flight (the plugin only sets `cursor: progress`). |
| `aria-busy="true"` on the content container | While its replacement is loading; cleared before focus moves to the new content. In full-body mode the main landmark (`main`/`[role="main"]`) carries it, never `<body>`/`<html>`. |
| `#ajxl-loader.ajxl-loader.ajxl-loader--{bar,spinner,overlay,text,logo,skeleton}` | The loading indicator (`ajxl-is-visible` while shown). Removed from the DOM when hidden. |
| `.ajxl-clip-x`, `.ajxl-clip-x-scroll` | Set by `transitions.js` on one ancestor of a content container (never `html`/`body`) while a horizontal slide runs (`overflow-x: clip`, so the page never scrolls sideways; `.ajxl-clip-x-scroll`, `overflow-x: hidden`, on an ancestor with `overflow-x: auto`, so it stays a scroll container); removed when the transition ends or is cancelled. A container that is a direct child of `<body>` uses a fade instead of a horizontal slide. |
| `.ajxl-tr`, `.ajxl-leave`, `.ajxl-enter`, `[data-ajxl-tr="fade"]` … | Transition state on the animated containers; removed when the transition ends. |
| `html.ajxl-rm` | Set when "Respect reduced motion" is on; transitions are disabled under `prefers-reduced-motion: reduce`. |
| `#ajxl-announcer.ajxl-sr-only` | The polite live region used for announcements. |
| `--ajxl-color`, `--ajxl-bg`, `--ajxl-size`, `--ajxl-bar-height`, `--ajxl-duration`, `--ajxl-easing` … | Custom properties set inline by the loader and transitions (see the header of `assets/src/css/frontend.css`). |

---

## 5. PHP hooks

Every hook the plugin fires (`grep -rn "apply_filters\|do_action" includes admin`). `$settings` is
the effective settings array (safe-mode overrides applied); for the filters marked *with defaults*
it is the same array merged over the built-in defaults (they run while `get_config()` builds the
page configuration).

### 5.1 Filters

| Filter | Parameters | Return | Fired in |
|---|---|---|---|
| `ajax_loading_enabled` | `bool $active`, `array $settings` | bool | `WPAJXL_Frontend::is_active_for_request()` – not applied where the engine can never run (admin, REST, AJAX, cron, feeds, embeds, Customizer, AMP, login, page-builder editors) |
| `ajax_loading_bypass` | `bool $bypass`, `array $settings` | bool | `WPAJXL_Frontend::is_bypass_page()` |
| `ajax_loading_frontend_config` | `array $config`, `array $settings` (*with defaults*) | array | `WPAJXL_Frontend::get_config()` |
| `ajax_loading_content_selectors` | `string[] $selectors`, `array $settings` (*with defaults*) | string[] | `get_config()` → `candidateSelectors` |
| `ajax_loading_head_selectors` | `string[] $selectors`, `array $settings` (*with defaults*) | string[] | `get_config()` → `seo.headSelectors` |
| `ajax_loading_legacy_callback_delay` | `int $delay` (default `0`), `array $settings` (*with defaults*) | int, clamped to 0–10000 | `get_config()` → `legacyCallbackDelay` |
| `ajax_loading_disable_core_speculative_loading` | `bool $disable`, `array $settings` | bool | `WPAJXL_Frontend::filter_speculation_rules()` |
| `ajax_loading_exclude_selectors` | `string[] $selectors`, `array $settings` (*with defaults*) | string[] | `WPAJXL_Exclusions::for_config()`, called from `get_config()` |
| `ajax_loading_exclusions` | `array $exclusions` (`urls`, `selectors`, `prefetch` string lists; `woocommerce`, `membership` booleans: whether destinations with WooCommerce cart/checkout/account/order or membership checkout/billing/cancel body classes load normally), `array $settings` (*with defaults*) | array (booleans returned for the two flags are honoured, other values ignored) | `WPAJXL_Exclusions::for_config()`, after `ajax_loading_exclude_selectors` |
| `ajax_loading_integrations` | `array $integrations` (key ⇒ `label`, `description`, `active`, `version`, `modes`, `default`) | array | `WPAJXL_Compat::integrations()` |
| `ajax_loading_settings` | `array $settings` | array (merged over the stored settings) | `WPAJXL_Settings::all()` |
| `ajax_loading_settings_schema` | `array $schema` | array | `WPAJXL_Settings::schema()` |

### 5.2 Actions

| Action | Parameters | Fired |
|---|---|---|
| `ajax_loading_enqueue_scripts` | – | After the front-end script and stylesheet were enqueued (only on pages where the engine runs). |
| `ajax_loading_settings_updated` | `array $old`, `array $new` | After the `_wp_ajax_loading` option was added or updated. |
| `ajax_loading_upgraded` | `string $from`, `string $to` | After stored data was migrated to a newer plugin version (`$from` is `''` when unknown). |

### 5.3 Examples

```php
// Turn the engine off for one request type. Prefer ajax_loading_bypass for single pages (see note).
add_filter( 'ajax_loading_enabled', function ( $active, $settings ) {
	return is_search() ? false : $active;
}, 10, 2 );
```

**Note:** `ajax_loading_enabled` returning `false` removes the marker from that page. When an AJAX
page links to it, the engine fetches it, finds no marker and falls back with `marker_missing` (an
expected fallback that never counts towards the circuit breaker). The request is still wasted, so to
exclude individual pages prefer `ajax_loading_bypass` (prints a `bypass` marker) or, better, a URL
exclusion (the page is not even fetched).

```php
// Load these pages normally (the marker says "bypass"; links to them fall back after the fetch).
add_filter( 'ajax_loading_bypass', function ( $bypass, $settings ) {
	return $bypass || is_page( array( 'configurator', 'quote' ) );
}, 10, 2 );

// Adjust the configuration sent to the browser.
add_filter( 'ajax_loading_frontend_config', function ( $config, $settings ) {
	$config['timeout']            = 15000;     // ms, 2000–60000
	$config['scroll']['behavior'] = 'content'; // top | content | none
	return $config;
}, 10, 2 );

// Add a content-area candidate for auto-detection (see recipe 8.6 for how candidates win).
add_filter( 'ajax_loading_content_selectors', function ( $selectors, $settings ) {
	array_unshift( $selectors, '#page-content' );
	return $selectors;
}, 10, 2 );

// Keep an extra head tag in sync on every navigation.
add_filter( 'ajax_loading_head_selectors', function ( $selectors, $settings ) {
	$selectors[] = 'meta[name="theme-color"]';
	return $selectors;
}, 10, 2 );

// Call legacy 1.x callbacks one second after completion, like 1.x did.
add_filter( 'ajax_loading_legacy_callback_delay', function () {
	return 1000;
} );

// Keep WordPress speculative loading (6.8+) on pages that use AJAX navigation.
add_filter( 'ajax_loading_disable_core_speculative_loading', '__return_false' );

// Links inside this element always load normally.
add_filter( 'ajax_loading_exclude_selectors', function ( $selectors, $settings ) {
	$selectors[] = '.my-modal-links';
	return $selectors;
}, 10, 2 );

// URL, selector and prefetch exclusions in one place.
add_filter( 'ajax_loading_exclusions', function ( $exclusions, $settings ) {
	$exclusions['urls'][]      = '/members/';   // this page and everything below it
	$exclusions['urls'][]      = 'download=';   // any URL containing this text
	$exclusions['selectors'][] = '.js-no-ajax';
	$exclusions['prefetch'][]  = '/reports/';   // still AJAX-navigated, never prefetched
	return $exclusions;
}, 10, 2 );

// Never AJAX-swap Elementor content on this site (restricting the modes forces "exclude").
add_filter( 'ajax_loading_integrations', function ( $integrations ) {
	if ( isset( $integrations['elementor'] ) ) {
		$integrations['elementor']['modes']   = array( 'exclude', 'off' );
		$integrations['elementor']['default'] = 'exclude';
	}
	return $integrations;
} );

// Read-time override (values are not sanitised again: keep the types).
add_filter( 'ajax_loading_settings', function ( $settings ) {
	if ( 'staging' === wp_get_environment_type() ) {
		$settings['debug_mode'] = true;
	}
	return $settings;
} );

// Different defaults (used for new installs, "Restore defaults" and keys missing from the option).
add_filter( 'ajax_loading_settings_schema', function ( $schema ) {
	$schema['loader_type']['default'] = 'spinner';
	return $schema;
} );

// Enqueue your own navigation code only on pages where the engine runs.
add_action( 'ajax_loading_enqueue_scripts', function () {
	wp_enqueue_script( 'my-theme-navigation', get_theme_file_uri( 'js/navigation.js' ), array(), '1.0.0', true );
} );

// React to settings changes (e.g. purge your page cache).
add_action( 'ajax_loading_settings_updated', function ( $old, $new ) {
	if ( ( $old['loader_type'] ?? '' ) !== ( $new['loader_type'] ?? '' ) ) {
		my_purge_page_cache(); // your own function
	}
}, 10, 2 );

add_action( 'ajax_loading_upgraded', function ( $from, $to ) {
	error_log( sprintf( 'AJAX Loading upgraded from %s to %s', $from ? $from : 'unknown', $to ) );
}, 10, 2 );
```

Pattern syntax for URL exclusions (shared by PHP and JavaScript, `utils.js` `matchesPattern`):

| Pattern | Matches |
|---|---|
| `/shop/` | `/shop`, `/shop/` and everything below it |
| `/shop` | Exactly `/shop` or `/shop/` |
| `/` | Only the home page |
| `/blog/*/amp` | `*` matches any characters; the pattern is anchored |
| `/search/?q=x` | Containing `?`: compared with path + query |
| `https://example.com/landing/` or `//example.com/landing/` | Host must match, the rest is a path pattern |
| `add-to-cart=` | Anything else: case-insensitive substring of path + query (`*` allowed) |

Patterns are also tested against the decoded form of percent-encoded paths, so non-ASCII slugs can be
written either way. A `#fragment` in a pattern is ignored.

Integration filter caveat: `ajax_loading_integrations` can relabel built-in integrations, restrict
their modes or add rows to the registry. A row you add appears in the integrations table, its mode is
saved with the settings (the settings schema picks up the registered keys and their allowed modes)
and it is passed to the browser in `ajaxLoadingConfig.integrations`, where your own code can read it
(`AjaxLoading.settings.integrations.my_key`). The browser engine only has adapters for the built-in
keys, so a new key has no effect on navigation by itself. Add the filter when your plugin loads or
from an `init` callback: the registry and the settings schema are built once `init` has finished.

---

## 6. REST endpoints

Namespace `ajax-loading/v1` (`includes/class-ajax-loading-rest.php`). Admin routes use the normal
REST authentication (cookie + `X-WP-Nonce`, or application passwords) and require
`manage_options`.

| Route | Method | Permission | Request | Response |
|---|---|---|---|---|
| `/log` | `POST` | Public, but refused with `403` unless "Record navigation errors" (`error_logging`) is on | JSON (or a JSON string sent as `text/plain` by `navigator.sendBeacon`, or form fields): `{ code, path, status?, message?, nav? }` | `201 { "logged": true }` |
| `/log` | `GET` | `manage_options` | – | Array of `{ time, code, path, status, message, nav }`, newest first; header `X-WP-Total` |
| `/log` | `DELETE` | `manage_options` | – | `{ "deleted": true, "count": 0 }` |
| `/diagnostics` | `GET` | `manage_options` | – | `{ environment: [{ id, status, label, message }], client: { time, ok, checks } \| null, generated }` |
| `/diagnostics` | `POST` | `manage_options` | `{ ok?: boolean, checks: [{ id?, label, status, message? }] }` | The stored summary `{ time, ok, checks }` |

`POST /log` validation:

- `code` must be one of the engine's error codes (list in
  [architecture.md § Failure handling](architecture.md#12-failure-handling)).
- `path` must be a root-relative path; the query string and fragment are dropped, only RFC 3986 path
  characters are kept, at most 300 characters.
- `status` 0–599; `nav` one of `link`, `popstate`, `form`, `programmatic`.
- `message` is sanitised, URL query strings, e-mail addresses and IPv4 addresses are removed, at most
  200 characters.
- Bodies over 4 KB get `413`. A global rate limit allows 30 entries per 10 minutes (`429` after
  that). The log keeps the last 50 entries in the non-autoloaded option `_wp_ajax_loading_log`.

`POST /diagnostics` keeps at most 50 checks; statuses are `ok`, `info`, `warning`, `error`; labels are
limited to 100 and messages to 300 characters. The summary is stored in the non-autoloaded option
`_wp_ajax_loading_diagnostics`.

```sh
# Read the client error log with an application password.
curl -u admin:"xxxx xxxx xxxx xxxx xxxx xxxx" https://example.com/wp-json/ajax-loading/v1/log
```

```js
// From an admin screen that loads wp-api-fetch.
wp.apiFetch( { path: '/ajax-loading/v1/diagnostics' } ).then( function ( report ) {
	console.table( report.environment );
} );
```

---

## 7. Page configuration, marker and legacy globals

### 7.1 `window.ajaxLoadingConfig`

Printed as an inline script before the engine (`#ajax-loading-WPajaxLoad-js-before`), JSON-encoded
with `JSON_HEX_TAG | JSON_HEX_AMP`. Built by `WPAJXL_Frontend::get_config()` from the effective
settings and filterable with `ajax_loading_frontend_config`. The engine reads it once, when the
bundle runs, and normalises every value (`settings.js`); changing the object later has no effect.

| Key | Content |
|---|---|
| `version`, `cacheVersion` | Plugin version; client-cache version (bumped by "Clear navigation cache", import and reset). |
| `debug`, `safeMode` | Debug logging; safe mode flag (informational, the overrides are applied server side). |
| `homeUrl`, `adminUrl`, `loginUrl` | Used by the URL rules. |
| `restLogUrl` | `…/wp-json/ajax-loading/v1/log` when error logging is on, otherwise `null`. |
| `isLoggedIn` | Logged-in visitors are never prefetched for or cached. |
| `devices` | `{ desktop, mobile, breakpoint }`. |
| `mode`, `contentSelectors`, `candidateSelectors` | Content detection (`auto` / `custom` / `body`). |
| `headerSelector`, `footerSelector`, `headerMode`, `footerMode` | Header/footer detection; `preserve` or `replace`. |
| `exclusions` | `{ urls, selectors, prefetch, woocommerce, membership }` (the last two are booleans: the protection switches). |
| `personalizedCookies` | Cookie-name prefixes that disable prefetch and caching. |
| `interactiveBlocks`, `handle404` | `reload`/`replace`; whether 404 pages are AJAX-rendered. |
| `forms` | `{ search, selectors }`. |
| `scripts` | `{ executeContent, refreshLocalized, runNewInline, dispatchDomEvents, removeStaleStyles }`. |
| `loader`, `transition` | Loading indicator and transition options. |
| `scroll`, `a11y` | `{ behavior, restore }`, `{ focus, announce }`. |
| `prefetch`, `cache`, `timeout` | Prefetch triggers and limits, in-memory cache limits, request timeout (ms). |
| `state` | `{ forms, menus }`. |
| `seo` | `{ metadata, canonical, structuredData, headSelectors }`. |
| `analytics` | `{ mode: none\|gtag\|datalayer, gtmEvent, timing, requireConsent, callback }`. |
| `integrations` | Mode per integration key. |
| `failures` | `{ autoDisable, threshold, minutes, logging }`. |
| `legacyCallbackDelay` | Delay before the legacy `wp_ajax_load_complete()` call. |
| `i18n` | Translated announcement strings. |

### 7.2 The marker `<meta name="ajax-loading">`

Printed at `wp_head` priority 1 on every front-end page where the engine may run:

```html
<meta name="ajax-loading" content="enabled" data-version="2.0.0" data-cache="1" data-site="3f2a9c0b71de" data-personalized="1" data-body-class="page-template-default page page-id-2 logged-in" />
```

| Attribute | Meaning |
|---|---|
| `content` | `enabled` (engine and assets loaded) or `bypass` (page loads normally, no assets). No marker at all when the plugin is off, paused, in "admins only" mode for this visitor, or the request type never uses the engine. |
| `data-version` | Plugin version. A destination page reporting another version loads normally (`asset_changed`). |
| `data-cache` | Client-cache version; a change empties the tab's in-memory cache. |
| `data-site` | Site identity: the first 12 hex characters of `md5( get_current_blog_id() . '\|' . untrailingslashit( siteurl ) )` (`WPAJXL_Frontend::site_id()`). A fetched page whose `data-site` differs from the current page's (another site of a subdirectory multisite network) loads normally (`bypass`, never counted). Pages without it are accepted. |
| `data-personalized` | Present (`1`) when the request is personalised: logged in, a WooCommerce session, or a `wp-postpass_`/`comment_author_` cookie. Such pages are never cached or used as a prefetch source. |
| `data-body-class` | The server-rendered body classes (`get_body_class()`), so the engine can tell them apart from classes scripts added: classes of the previous page are removed exactly, script classes are kept. On `enabled` pages a small inline script printed right after the marker (`#ajax-loading-body-class-js`) replaces the value with the class attribute of the parsed `<body>` before any body script runs, so classes the template passes to `body_class( $class )`, classes written into the `<body>` tag and classes added by a later `body_class` filter count as server classes too. |

The engine refuses to start on a page without an `enabled` marker, and every fetched page must carry
one too. The marker is part of the synced head tags, so it always describes the page on screen.
The marker and `window.reload_helper` are printed on `bypass` pages too, and those pages keep jQuery
when "Load jQuery for custom callbacks" (`legacy_jquery`) is on, like 1.x.

### 7.3 `window.reload_helper` (1.x global)

Defined on every page with a marker (pages where the engine runs and `bypass` pages) in the head, and
again in the inline configuration where the engine runs, without
overwriting keys that site code already set: `rootUrl` (now `home_url()`), `ids`, `container_id`,
`mcdc`, `searchID`, `transition`, `scrollTop`, `loader`, `bp_status`. When the engine starts
(`DOMContentLoaded`) it honours two of them:

- `ids` – a CSS selector string; links inside matching elements load normally.
- `container_id` – a selector other than `body`; added as the first auto-detect candidate.

The other keys are kept only so old code that reads them does not break.

---

## 8. Recipes

### 8.1 Re-initialise a slider or lightbox after navigation

```js
( function () {
	var instances = [];

	function init( root ) {
		root.querySelectorAll( '.my-slider:not([data-my-slider])' ).forEach( function ( el ) {
			el.setAttribute( 'data-my-slider', '' );
			instances.push( new MySlider( el ) );
		} );
	}

	// First page load.
	if ( document.readyState === 'loading' ) {
		document.addEventListener( 'DOMContentLoaded', function () { init( document ); } );
	} else {
		init( document );
	}

	// The old content is about to be removed: release timers and listeners.
	document.addEventListener( 'wp_ajax_load_before_replace', function () {
		instances.forEach( function ( slider ) { slider.destroy(); } );
		instances = [];
	} );

	// New content is in place and its scripts ran.
	document.addEventListener( 'wp_ajax_load_complete', function ( event ) {
		event.detail.containers.forEach( init );
	} );
} )();
```

Destroying in `wp_ajax_load_before_replace` is safe because nothing has been swapped yet; if the
swap later fails the page is reloaded anyway. If a listener cancels the event, the page is reloaded
too.

### 8.2 Run code once per real page vs once per AJAX page

```js
// Once per real page load (global listeners, analytics bootstraps, service workers …).
document.addEventListener( 'DOMContentLoaded', setUpGlobalStuff );
// or, only when the engine actually started:
document.addEventListener( 'wp_ajax_load_ready', function ( event ) {
	console.log( 'AJAX Loading', event.detail.version, 'active on', event.detail.url );
} );

// Once per page shown, real or AJAX.
function onEveryPage( root ) { /* … */ }
document.addEventListener( 'DOMContentLoaded', function () { onEveryPage( document ); } );
document.addEventListener( 'wp_ajax_load_complete', function ( event ) {
	onEveryPage( event.detail.container || document );
} );
```

Keep in mind: a script **inside** the content area runs again after every AJAX navigation that
brings it along, while an external script that is already loaded never runs twice. Make
initialisers idempotent (mark processed elements, as in 8.1).

### 8.3 Cancel a navigation in `wp_ajax_load_before_replace`

```js
document.addEventListener( 'wp_ajax_load_before_replace', function ( event ) {
	var doc = event.detail.newDocument; // parsed, inert destination page
	if ( doc.querySelector( '.requires-full-page-load' ) ) {
		event.preventDefault(); // the engine loads event.detail.url with a normal page load
	}
} );
```

`event.detail.newDocument` is also available in `wp_ajax_load_replaced`. Do not move its nodes into
the page yourself: the engine adopts them and runs their scripts according to its policy.

### 8.4 Send a custom analytics event

The built-in analytics setting covers GA4 `gtag` page views and a GTM `dataLayer` event. For
anything else, listen to `complete` (fires only after a successful AJAX navigation, never for the
first page view):

```js
document.addEventListener( 'wp_ajax_load_complete', function ( event ) {
	var d = event.detail;
	window._paq = window._paq || [];
	window._paq.push( [ 'setReferrerUrl', d.previousUrl ] );
	window._paq.push( [ 'setCustomUrl', d.url ] );
	window._paq.push( [ 'setDocumentTitle', d.title ] );
	window._paq.push( [ 'trackPageView' ] );
} );
```

Alternatively set **Custom tracking function** (`analytics_callback`, SEO & Integrations tab) to a
dotted global path, for example
`myTheme.trackPageView`. It is resolved without `eval` and called after each successful navigation
with a frozen copy of the detail plus `page_location`, `page_path`, `page_title` and `page_referrer`:

```js
window.myTheme = window.myTheme || {};
window.myTheme.trackPageView = function ( data ) {
	navigator.sendBeacon( '/my-stats', JSON.stringify( { path: data.page_path, ms: data.duration } ) );
};
```

### 8.5 Exclude a URL or a selector from PHP

```php
add_filter( 'ajax_loading_exclusions', function ( $exclusions ) {
	$exclusions['urls'][]      = '/booking/';
	$exclusions['selectors'][] = '.booking-widget';
	return $exclusions;
} );
```

URL exclusions are applied before any request; `ajax_loading_bypass` is only discovered after the
destination page was fetched, so prefer URL exclusions for pages that are linked often.

### 8.6 Register a content selector

The content area is chosen like this (`content.js` `autoContent`):

1. Explicit selectors in order: the **Content selectors** setting (in Automatic mode they are tried
   before the built-in candidates), then `[data-ajax-loading-container]`. The first one that exists
   exactly once on both pages and passes the layout check wins.
2. Otherwise every candidate from `candidateSelectors` that passes is collected, and the
   **outermost** one wins.

So adding a selector with `ajax_loading_content_selectors` makes it eligible but does not force it
when a wrapping candidate (such as `#content`) is valid too. To force it, mark the element with
`data-ajax-loading-container`, put the selector in the **Content selectors** setting, or reduce
the candidate list:

```php
add_filter( 'ajax_loading_content_selectors', function () {
	return array( '#page-content' ); // only this candidate (plus [data-ajax-loading-container])
} );
```

### 8.7 Make a theme compatible

1. Wrap the page-specific part of every template in one element and mark it:
   ```php
   <main id="primary" class="site-main" data-ajax-loading-container>
   	<?php /* loop */ ?>
   </main>
   ```
   Use the same element, id and nesting on every template. Keep the site header and footer outside
   it. When the header differs per page type (for example a different header on archives), those
   navigations fall back to a normal load (`layout_mismatch`), which is correct and never counted
   as an error; set **Header** to "Replace it on every navigation" if the header should be swapped
   instead.
2. Initialise per-page JavaScript on `wp_ajax_load_complete` (recipe 8.1). Do not rely on
   `DOMContentLoaded` or `window.load` for content.
3. Mark the theme's main stylesheet or script with `data-ajax-loading-track="reload"` if a version
   change must force a full reload:
   ```php
   add_filter( 'style_loader_tag', function ( $tag, $handle ) {
   	return 'my-theme' === $handle ? str_replace( '<link ', '<link data-ajax-loading-track="reload" ', $tag ) : $tag;
   }, 10, 2 );
   ```
4. Use **Settings → AJAX Loading → Tools & Diagnostics** to check which container auto-detect
   finds on sample pages.

### 8.8 Migrate a 1.x `wp_ajax_load_complete` function

1.x:

```js
wp_ajax_load_complete = function () {
	jQuery( '.slider' ).slick();
};
```

2.0 (multiple listeners, the new container, no fixed delay):

```js
document.addEventListener( 'wp_ajax_load_complete', function ( event ) {
	jQuery( event.detail.container ).find( '.slider' ).not( '.slick-initialized' ).slick();
} );
```

The 1.x form keeps working unchanged. Two things to check when migrating:

- **jQuery**: 2.0 does not need jQuery. Sites upgraded from 1.x get the **Load jQuery for custom
  callbacks** setting (`legacy_jquery`) switched on, fresh installs do not. If your callback uses
  jQuery, enqueue it yourself or keep that setting on.
- **Timing**: 1.x called the function about one second after the swap; 2.0 calls it right after the
  navigation completed. Use `ajax_loading_legacy_callback_delay` if your code relied on the delay.

The full upgrade path for site owners is in [migration.md](migration.md).

---

## 9. Building from source

The plugin ships the built files in `assets/dist/` and their source in `assets/src/`; you only need
Node.js to change the JavaScript or CSS. The build script is `tools/build.mjs`. The local site and the
test suite (`tests/`) are part of the development checkout, not of the plugin package downloaded from
WordPress.org (see [testing.md](testing.md)):

```sh
npm install
npm run build   # assets/src → assets/dist (+ sizes.json)
npm run dev     # local WordPress Playground site
npm test        # unit + end-to-end tests
```

Current bundle sizes (`assets/dist/sizes.json`): `frontend.min.js` 173,129 bytes raw / 59,885 bytes
gzip, `frontend.min.css` 8,282 / 2,242 bytes. The engine is loaded deferred in the footer, and only
on pages where it runs. All bundle sizes and the latest test results are in [testing.md](testing.md).
