# AJAX Loading 2.0 – architecture

This document explains how the 2.0 engine works, for the maintainer and for contributors. It
describes the code as shipped in 2.0.0; file and function names are given so every statement can
be checked. The public API (events, `window.AjaxLoading`, hooks, REST) is documented in
[developer-guide.md](developer-guide.md); integration details are in
[compatibility.md](compatibility.md).

Contents

1. [Design principles](#1-design-principles)
2. [Module map](#2-module-map)
3. [Engine start-up](#3-engine-start-up)
4. [Navigation lifecycle](#4-navigation-lifecycle)
5. [Link, URL and form rules](#5-link-url-and-form-rules)
6. [Content detection](#6-content-detection)
7. [History model](#7-history-model)
8. [Metadata sync](#8-metadata-sync)
9. [Script and style policy](#9-script-and-style-policy)
10. [Request layer](#10-request-layer)
11. [Cache and prefetch](#11-cache-and-prefetch)
12. [Failure handling](#12-failure-handling)
13. [Accessibility, loader and transitions](#13-accessibility-loader-and-transitions)
14. [Server side](#14-server-side)
15. [Security model](#15-security-model)
16. [Known limitations](#16-known-limitations)

---

## 1. Design principles

- **Normal navigation is always the fallback.** Every check that fails, every unexpected exception
  and every unsupported situation ends in a normal page load (`hardNavigate`). The engine never
  falls back to a "best effort" swap.
- **Only what changes is swapped.** The live container elements stay in place (their attributes are
  synced and their children replaced), so delegated listeners and skip-link targets survive.
- **Parse inert, execute deliberately.** Responses are parsed with `DOMParser` into a document that
  runs nothing. Scripts run only through the AssetManager, one at a time, as fresh `<script>`
  elements.
- **No side effects at import time.** Every module exports a `createX()` factory; the admin bundle
  and the unit tests import the same modules.
- **No jQuery, no external services.** The front-end bundle is vanilla JavaScript (ES2018). jQuery is
  only loaded when the `legacy_jquery` setting is on (default for sites upgraded from 1.x). Nothing
  is sent anywhere except the optional same-site error log (`POST /wp-json/ajax-loading/v1/log`).

---

## 2. Module map

### 2.1 PHP

| File | Class | Responsibility |
|---|---|---|
| `ajax-loading.php` | – | Plugin header, constants (`WPAJXL_*`, 1.x names kept), requires, activation hook, `run_WP_Ajax_loading()`. Returns early when another copy is already loaded. |
| `includes/class-ajax-loading-core.php` | `WPAJXL_Core` (alias `WP_Ajax_loading_Core`) | Wires settings, front end, REST and admin once per request; schedules `maybe_upgrade()` on `plugins_loaded` priority 5; activation. |
| `includes/class-ajax-loading-settings.php` | `WPAJXL_Settings` | Option `_wp_ajax_loading`: schema, defaults, sanitising, read (`stored()`/`all()`/`effective()`), safe mode, pause, export/import, cache version, migration from 1.x, `ajax_loading_settings_updated`. |
| `includes/class-ajax-loading-frontend.php` | `WPAJXL_Frontend` | Decides whether the engine runs for a request, prints the marker, enqueues assets and `window.ajaxLoadingConfig`, legacy `reload_helper`, speculative-loading and emoji switches. |
| `includes/class-ajax-loading-exclusions.php` | `WPAJXL_Exclusions` | Built-in URL patterns and link selectors (core, WooCommerce, membership, post types), the `exclusions` config block, the server-side bypass decision, the read-only rule list for the admin. |
| `includes/class-ajax-loading-compat.php` | `WPAJXL_Compat` | Integration registry (labels, allowed modes, defaults, server-side detection) and the environment report on Tools & Diagnostics. |
| `includes/class-ajax-loading-rest.php` | `WPAJXL_REST` | Routes `ajax-loading/v1/log` and `/diagnostics`: validation, permissions, rate limit. |
| `includes/class-ajax-loading-logger.php` | `WPAJXL_Logger` | Client error log: ring buffer of 50 entries in the non-autoloaded option `_wp_ajax_loading_log`. |
| `admin/class-ajax-loading-admin.php` | `WPAJXL_Admin` (alias `WP_ajax_loading_Admin`) | Settings → AJAX Loading (`ajax-loading-admin`): Settings API registration, tabs, quick actions via `admin-post.php`, notices, plugin action links, legacy slug/tab redirects, admin assets on its own screen only. |
| `admin/views/*.php` | – | One template per tab (overview, general, loading, performance, seo, exclusions, tools, about, thanks) plus `class-ajax-loading-admin-fields.php` (field definitions and renderer). |
| `uninstall.php` | – | Deletes the plugin's options and transients (every site on multisite). |

### 2.2 Front-end JavaScript (`assets/src/js/frontend/`, bundled to `assets/dist/frontend(.min).js`)

| Module | Responsibility |
|---|---|
| `index.js` | Entry: exposes `window.AjaxLoading`, folds in `reload_helper`, checks start-up guards, builds every manager and starts the engine on `DOMContentLoaded`. |
| `settings.js` | `DEFAULT_CONFIG`, `createSettings()` (normalised, clamped, frozen settings with device and reduced-motion helpers), `isSupportedBrowser()`. |
| `events.js` | Event names, `emit`/`on`/`off`, legacy global callbacks, jQuery mirror. |
| `utils.js` | URL helpers (`toURL`, `pageKey`, `isSamePage`, `stripHash`), exclusion pattern matching, safe DOM queries, logger, `NavError`. |
| `navigation.js` | The orchestrator: click handling, `prepare`/`run`/`fail`, validation, swap order, scroll, popstate, bfcache. |
| `links.js` | Eligibility rules for clicks (`checkLink`), URLs (`checkUrl`) and GET forms (`checkForm`). |
| `forms.js` | Delegated `submit` listener for eligible GET forms. |
| `request.js` | `fetch` with timeout, response validation, charset decoding, in-flight sharing with prefetches, `DOMParser` parsing. |
| `history.js` | Entry tagging (`history.state.__ajxl`), push/replace, popstate classification, scroll and form-state stores, observer of foreign `pushState`/`replaceState`. |
| `content.js` | Content/header/footer detection, layout checks, DOM replacement, admin bar and menu state sync, form-state snapshots. |
| `assets.js` | Stylesheet sync (before the swap) and clean-up (after), script planning and sequential execution. |
| `metadata.js` | Title, managed head tags, body-level JSON-LD, `<html lang/dir>`, body classes. |
| `compat.js` | Integration adapters: bypass verdicts, script hints, re-initialisation after the swap. |
| `failures.js` | Error codes, countable errors, circuit breaker, client error reports, `hardNavigate`. |
| `cache.js` | In-memory page cache (LRU, TTL, byte budget, version). |
| `prefetch.js` | Hover/focus/touch/viewport prefetching with gates and a small queue. |
| `a11y.js` | Live region, `aria-busy`, focus after navigation. |
| `loader.js` | Loading indicator (bar, spinner, overlay, text, logo, skeleton). |
| `transitions.js` | Leave/enter transitions on the replaced containers. |
| `analytics.js` | Virtual page views (`gtag`, `dataLayer`, callback, 1.x trackers). |

### 2.3 Admin JavaScript (`assets/src/js/admin/`, bundled to `assets/dist/admin(.min).js`)

| Module | Responsibility |
|---|---|
| `index.js` | Entry; progressive enhancement of the settings screen. |
| `data.js` | Reads `window.ajaxLoadingAdmin` (REST URL, nonce, config, sample URLs, i18n). |
| `api.js` | REST client for `ajax-loading/v1` (cookie + `X-WP-Nonce`). |
| `fields.js`, `form-values.js` | Colour/logo pickers, conditional rows, unsaved-changes guard; maps unsaved form values onto the front-end config shape. |
| `preview.js` | Live loader/transition preview using the real `loader.js` and `transitions.js`. |
| `diagnostics.js`, `inspect.js` | Navigation test and content-selector check using the real `links.js`, `content.js` and the `exclude`-mode markers of `compat.js` on fetched pages; environment report. |
| `error-log.js` | Refresh and clear the error log. |
| `copy.js`, `dom.js` | Copy buttons for snippets; DOM helpers (text only, never HTML). |

CSS: `assets/src/css/frontend.css` (loader, transitions, `.ajxl-sr-only`, `html.ajxl-navigating`) and
`admin.css`. Build: `tools/build.mjs` (`npm run build`); tests, latest results and bundle sizes:
[testing.md](testing.md).

---

## 3. Engine start-up

`index.js` `boot()` runs when the deferred footer script executes:

1. If `window.AjaxLoading` already exists (bundle included twice), stop.
2. Read `window.ajaxLoadingConfig` once, create the event manager (with `legacyCallbackDelay`) and
   expose `window.AjaxLoading`.
   When the bundle runs while the document is still parsing (WordPress < 6.3 ignores the `defer`
   strategy, or an optimiser moved the script), a `readystatechange` listener records the server
   structure (`snapshotServerStructure()`) and the id-less `<style>` elements
   (`snapshotServerStyles()`) the moment parsing ends, before `DOMContentLoaded` handlers such as
   jQuery ready callbacks insert their elements. The deferred bundle runs after parsing anyway and
   the managers take that snapshot when they are created.
3. On `DOMContentLoaded` (or immediately if it already fired) `start()`:
   - config missing → stop; `isSupportedBrowser()` false → stop;
   - `createSettings(applyLegacyHelper(raw))` – `reload_helper.ids` becomes an exclusion selector,
     `reload_helper.container_id` (other than `body`) the first auto-detect candidate;
   - `blockedReason()`: Customizer preview (`window.wp.customize`), marker missing, marker not
     `enabled`, or the WordPress Interactivity router present on the page (import map entry or
     module/modulepreload for `@wordpress/interactivity-router`) → stop;
   - circuit breaker open (`failures.isOpen()`) → stop; device type disabled → stop;
   - build history, link rules, cache, request, content, assets, metadata and compat managers (any
     failure → stop); then a11y, loader, transitions, prefetch, analytics (optional);
   - `engine.init()`: tag the current history entry, attach `click` on `window` (bubble phase),
     `pagehide`/`pageshow`, the forms listener and prefetch triggers;
   - emit `wp_ajax_load_ready` `{ version, url }`.

When the engine does not start nothing is intercepted, and `AjaxLoading.navigate()` uses
`location.assign()`.

`isSupportedBrowser()` requires `fetch`, `AbortController`, `Promise`, `URL`, `DOMParser`,
`TextDecoder`, `Map`, `Set`, `WeakMap`, `history.pushState`/`replaceState`,
`Element.prototype.closest`/`matches`/`replaceChildren` and a constructible `CustomEvent`.

---

## 4. Navigation lifecycle

`navigation.js` splits a navigation into a synchronous `prepare()` and an asynchronous `run()`.

### 4.1 Synchronous part (`prepare`)

| Step | What happens |
|---|---|
| 0 | Link clicks are pre-checked by `onClick`: `defaultPrevented` or engine inactive → ignore; no anchor → ignore; `rules.checkLink()` fails → leave to the browser. |
| 1 | Not `http(s)` → native (click) / invalid. Engine inactive (disabled, breaker open, device disabled, a full load in progress) → native or `hardNavigate`. |
| 2 | Same page + `#fragment` (not fresh, not popstate/form) → native / `location.assign` (the browser scrolls). |
| 3 | Programmatic and popstate navigations run `rules.checkUrl()`; failure → `hardNavigate`. |
| 4 | **De-duplication**: a running navigation to the same page key (origin + path + query) is kept; a newer fragment is remembered. |
| 5 | A running navigation to another page is cancelled (`wp_ajax_load_cancel`, reason `superseded`, request aborted, UI handed over). |
| 6 | `wp_ajax_load_before` (cancelable). Prevented → `wp_ajax_load_cancel` (reason `prevented`) and the click is left to the browser / a full load. |

`replace` is true for programmatic `replace: true`, for `reload()`, and for a navigation to the page
already showing when that page is also the current history entry (`location.href`), so clicking the
current page re-renders it without adding a history entry. During a pending Back/Forward the page on
screen is no longer the current entry, so a click on it pushes a new entry instead of overwriting the
entry being loaded. `reload()` strips the `#fragment` before the URL rules (it re-renders the same
document) and keeps it for scrolling and focus.

### 4.2 Asynchronous part (`run`)

| Step | What happens |
|---|---|
| 1 | Save the scroll position of the current entry; snapshot GET-form values when `state.forms` is on (not for popstate). |
| 2 | Loading UI: `html.ajxl-navigating`, `aria-busy="true"` on the content container (in body mode on the page's first `main`/`[role="main"]` instead, never on `<body>`/`<html>`, and not at all when that element contains the live region), loader start (it shows after `loader.delay`; "Loading…" is announced only if it became visible), leave transition (capped at duration + 2 s). |
| 3 | `wp_ajax_load_start`. |
| 4 | `request.fetchPage()` (client cache first unless `fresh`; shares an in-flight prefetch); abort unadopted prefetches. Timeout: `timeout` setting (default 10 s). |
| 5 | Final URL = response URL with the requested `#fragment`. |
| 6 | `request.parse()` → inert document. |
| 7 | `validate()` (see 4.3) → regions. |
| 8 | `compat.refreshScriptIds()` / `rerunScriptPatterns()`; `assets.planScripts()` **before** the swap (adoption moves nodes; may throw `integration_excluded` for an incompatible import map). |
| 9 | Wait for the leave transition; `assets.syncStyles()` loads new stylesheets (each up to 3 s, the step capped at 8 s). |
| 10 | `wp_ajax_load_before_replace` (cancelable, `detail.newDocument`). Prevented → `prevented` → full load of the final URL. |
| 11 | **Swap, in this order**: (a) `document.title`; (b) history `push` (or `replace`; popstate only replaces when redirected); (c) `metadata.update()` (head tags, html attributes, body classes); (d) `content.replace(regions)`; then set the rendered URL, `assets.finalizeStyles()`, menu state sync. The title is set before `pushState` because GA4/GTM history listeners read it. |
| 12 | Scroll (see [7.4](#74-scroll)). |
| 13 | `wp_ajax_load_replaced`; enter transition starts; `assets.runScripts(plan)` (capped at 30 s); `compat.afterReplace()` (capped at 5 s); wait for the enter transition; next frame; re-apply a restored scroll position if late content moved it and the user did not scroll. |
| 14 | Clear `aria-busy` (before focus moves, so focus never lands inside a busy subtree); restore form values (popstate, `state.forms`); move focus; announce "Navigated to {title}". |
| 15 | `loader.done()` (respects `loader.minDuration`; the indicator may keep fading out); remove `html.ajxl-navigating`. |
| 16 | `analytics.track()`, reset the failure window, prefetch scan of the new content. |
| 17 | `wp_ajax_load_complete` – exactly once (guarded per navigation). |

After every `await` the engine calls `ensureCurrent(nav)`: if a newer navigation took over, the old
one throws an `AbortError` and stops silently (it already emitted `wp_ajax_load_cancel`).

### 4.3 Validation (`validate`)

In order; each failure throws a `NavError`:

1. Redirected to another page → the final URL must pass `checkUrl` → else `excluded_redirect`
   (full load of the final URL).
2. `<meta name="ajax-loading">` must exist → else `marker_missing` (never counted by the circuit
   breaker).
   If both the fetched and the live marker carry `data-site` and they differ (another site of a
   subdirectory multisite network on the same origin) → `bypass` (never counted). This is checked
   before anything else of the marker is used, so another site's `data-cache` never reaches this
   site's cache.
   Then its `data-cache` is passed to `cache.checkVersion()`; `data-personalized="1"` removes both
   URLs from the cache.
3. Marker `content` must be `enabled` → else `bypass`.
4. Marker `data-version` must equal the running version → else `asset_changed`.
5. `<html lang>` must not change (unless the multilingual integration is `off`) → else
   `language_changed`.
6. Destination `<body>` must not have `woocommerce-cart|checkout|account|order-received|order-pay`
   (when WooCommerce exclusions are on), `pmpro-checkout|billing|cancel` (membership exclusions on)
   or `buddypress` (BuddyPress integration `exclude`) → else `bypass`.
7. Elements marked `data-ajax-loading-track="reload"` must be identical (nonce, `async`, `defer`
   ignored) → else `asset_changed`.
8. `content.detect()` → regions, else `container_mismatch` / `layout_mismatch`.
9. `compat.shouldBypass(doc, regions)` → else `interactive_content` / `integration_excluded` /
   `language_changed`.

### 4.4 Failure, abort and supersession

`fail(nav, err)`:

- Navigation already done (superseded/cancelled) → nothing.
- `AbortError` before the swap (browser stopped the request) → `wp_ajax_load_cancel`
  (reason `aborted`), stay on the page.
- `prevented` → `wp_ajax_load_cancel` (reason `prevented`) + full load.
- Anything else → `wp_ajax_load_error` (`fallback: true`), circuit-breaker bookkeeping, optional
  report, then `hardNavigate(target, { replace })`. `replace` is true after the swap started, for
  popstate, and when the navigation replaces its entry (programmatic `replace: true`, `reload()`, a
  click on the current entry, a redirect back to it), so the full load takes that entry's place as
  the AJAX swap would have. The same applies to the `prevented` full load. The target is the original
  URL for `network`, `timeout` and `cross_origin_redirect`, the redirect target for
  `excluded_redirect`, otherwise the final URL.

`hardNavigate` sets a flag so further clicks go to the browser while the full load happens; the
flag resets after 10 s (downloads keep the page open) or on a bfcache restore. A same-page URL with
a fragment is reloaded with `location.reload()` because assigning it would only scroll.

Superseded navigations are cancelled in `prepare()`; on `pagehide` the running navigation is
cancelled (reason `pagehide`); `destroy()` cancels with `destroyed`.

---

## 5. Link, URL and form rules

`links.js`. Every rule returns `{ ok, reason, url, samePage }`; anything unexpected returns
`ok: false`. URLs are never decoded or re-encoded.

`checkLink(anchor, event)`: `defaultPrevented`, non-primary button, modifier keys, no `href`,
foreign `target` (own or `<base target>`), `download`, `rel="external"`, opt-out ancestor
(`[data-no-ajax], .no-ajaxy, .no-ajax, [data-ajax-loading="false"], #wpadminbar`), exclusion
selector → not ok; then `evaluate()`.

`evaluate(url)`: protocol `http(s)`; same origin as the window; path inside the home path; same page
with a fragment → `hash-only`; `wp-admin`; login/signup/activation (also the configured login URL);
files (`wp-content/`, `wp-includes/`, a long list of extensions, `.php` other than `index.php`);
query parameter names containing `nonce`; URL exclusion patterns. For prefetch also: prefetch
patterns, logged in, personalised page or cookie.

`checkForm(form, submitter)`: GET only (`formmethod` respected), no foreign target, no file input,
no opt-out/exclusion, must be a search form (when `forms.search`) or match `forms.selectors`. The
URL is built like the browser does (`FormData` with submitter where supported).

Exclusion patterns use the semantics in `utils.js` `matchesPattern()`, shared with
`WPAJXL_Exclusions` (see the developer guide for the syntax table).

---

## 6. Content detection

`content.js` `detect(currentDoc, newDoc)` returns regions `{ selector, kind, current, next }` or a
failure code. It never falls back to body replacement on its own.

### 6.1 Modes

- **body** – one region `{ kind: 'body', current: document.body, next: newDoc.body }`. All body
  children are replaced except `#wpadminbar`, the plugin's own UI (`#ajxl-*`, `.ajxl-loader`,
  `.ajxl-sr-only`) and body-level `script`/`style`/`link` (the AssetManager handles those). New body
  attributes are copied (class and style excluded).
- **custom** – every selector in `contentSelectors` must match the same, non-zero number of elements
  on both pages (paired by index). A match on `<body>` switches to body mode; a match containing the
  admin bar fails. Nested matches collapse to the outermost.
- **auto** – described below. Custom mode with an empty selector list behaves like auto.

### 6.2 Auto mode

1. **Landmarks.** Header and footer are paired first (`pairLandmark`): the configured selector, or
   the first of `HEADER_CANDIDATES` (`#masthead`, `#site-header`, `.site-header`, `#brx-header`,
   `.elementor-location-header`, `.et-l--header`, `header.wp-block-template-part`, `#main-header`,
   `#header`, `#branding`, `.fl-page-header`, `body > header`, `[role="banner"]`) /
   `FOOTER_CANDIDATES` that matches exactly once on the new page and is not inside `main`,
   `article`, `[role="main"]` or the admin bar.
2. **Explicit candidates**: `contentSelectors` from the settings, then
   `[data-ajax-loading-container]`. The first valid one wins.
3. **Default candidates** (`candidateSelectors`, filter `ajax_loading_content_selectors`):
   `[data-ajax-loading-container]`, `#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`. All valid ones are collected
   and the **outermost** wins (it leaves the fewest page-specific parts preserved).

A candidate is valid when (`evaluateCandidate`):

- it matches **exactly one** element on both pages, not `html`/`body`/`head` or inside `head`;
- it neither contains nor sits inside the header, the footer or `#wpadminbar` (on both pages);
- **layout check** (`layoutCheck`): same tag; same nesting depth below `<body>`; identical ancestor
  signatures (`tag#id.sorted.classes`, ignoring volatile classes such as `ajxl-*`, `current*`,
  `active`, `is-open`, `sticky`, `lazyloaded`, `js`/`no-js`, Elementor `elementor-sticky*`,
  `elementor-section--handles-inside`, `e-lazyloaded`, Divi `et-fixed-header`,
  `et-animated-content` and Bricks `slide-up`, `sliding`). The ContentManager records which live
  elements are server output, with their classes, when it is created (right after parsing, see
  [3](#3-engine-start-up)) and whenever it adopts nodes from a fetched page. A live element matches
  with its server classes or its current ones, so classes scripts add later never count. At every
  ancestor level, and at `<body>` level between the first and the last landmark
  (header/content/footer), the new page's children must equal the live server children. Elements
  scripts inserted after start-up are ignored wherever they are, and a server element only the
  current page has (a page title hero, a `<body>`-level hero) fails the check instead of staying on
  the new page. On a late start (the page had already finished loading: delayed/async JavaScript)
  the old tolerance applies: the new children must appear as one contiguous run among the live
  children. Siblings that differ only in static text/links/images (for
  example a breadcrumb or title bar outside the container) become **extra content regions**;
  siblings that differ and contain dynamic content (`script`, `iframe`, `form`, `video`,
  `[data-wp-interactive]`, Elementor widgets, sliders …) fail the check. At `<body>` level only the
  structure is checked for siblings with dynamic content or site chrome (`nav`, `button`,
  `[aria-expanded]`, `[aria-controls]`, `[data-toggle-target]`): Twenty Twenty's menu modal and
  footer widgets or GeneratePress' navigation below the header often carry per-page links (a
  language switcher), and replacing their children would drop the handlers the theme bound to them,
  so they are preserved as they are. The layout check of the admin **Test navigation**, which
  compares two fetched pages, treats every element of a fetched page as server output, so it reports
  the same result as the front end.

If no candidate is valid: `layout_mismatch` when at least one candidate existed on both pages but
failed the layout check, otherwise `container_mismatch`.

### 6.3 Header and footer skeleton checks

For each paired landmark outside the content regions:

- present on only one page → `layout_mismatch`;
- contains the content area → `layout_mismatch`;
- mode `replace` → added as a region;
- mode `preserve` → `compareLandmarks()`: signature without block layout classes and the child
  "skeleton" (its direct children, compared like the layout check: script helpers ignored, a server
  child only the live page has is a difference) must match, else `layout_mismatch`. If only the
  numbered block layout classes (`wp-container-*`, `wp-elements-*`) differ, the landmark is
  **replaced automatically** so it does not lose its per-page styles.

### 6.4 Replacement

`replace(regions)` (after `planScripts`): sync the per-page admin bar nodes (edit, view, customize,
site editor, comments) without replacing the bar; then for each region in order content, header,
footer: strip `<noscript>`, sync attributes (keeping `ajxl-*` classes, `data-ajxl-*` attributes,
`--ajxl-*` custom properties, `aria-busy` and an `id` the server markup lacks), and replace the
children with nodes adopted from the parsed document. The fetched root is marked
`data-ajxl-replaced` so the style sync knows the swap happened.

Menu state (`syncMenuState`, setting `state.menus`): outside the replaced regions, `current*`/`active`
classes and `aria-current` are copied from the new page for elements with ids (`menu-item-*`, ids in
`nav`/header/footer), and for id-less links matched by normalised href (block navigation).

---

## 7. History model

`history.js`.

### 7.1 Entries

- Each entry written by the engine carries `history.state.__ajxl = { key, idx, url }` (`key` random,
  `idx` increasing). `push()` writes it with `pushState`. `replace()` keeps the entry's `key` and
  `idx`, so saved scroll and form state stay attached.
- At start-up **one** `replaceState` tags the initial entry, merged into an existing plain-object
  state (never replacing foreign state). After a reload or a Back to a document that was evicted from
  the bfcache, the existing tag is reused.
- `history.scrollRestoration` is set to `manual` while the engine runs and restored on `pagehide`.
- No duplicate entries: a click to the page already showing replaces (when it is the current entry);
  repeated clicks to a loading page are de-duplicated; popstate never pushes.
- Redirects: a push uses the final URL; a non-popstate navigation whose final URL is the current
  entry (a redirect back to the page shown) replaces instead (`detail.direction` is `null`); a
  popstate that ends on another URL replaces the entry with the final URL.

### 7.2 Scroll and form state

- Scroll positions are kept in an in-memory `Map` mirrored to `sessionStorage['ajxl:scroll']`
  (50 entries), keyed by entry key (or `u:` + href for untagged entries). They are saved before each
  navigation, on `pagehide`, and from the last observed position on popstate. The engine never calls
  `replaceState` to store scroll positions.
- While a Back/Forward load is pending (the browser already moved to the destination entry but its
  page is not rendered yet), nothing is saved under the destination entry: scroll and form saves,
  `pagehide`, `hashchange`, a foreign `pushState` and a second popstate skip it, so a quick double
  Back never overwrites the middle entry's state. The flag clears when the destination is rendered.
- GET-form values (opt-in `state.forms`) are stored in `sessionStorage['ajxl:forms']` (20 entries,
  64 KB each). Password, hidden, file and button inputs and names matching
  `pass|pwd|token|nonce|secret|card|cvv|cvc|iban|ssn` are never stored. Restored values do not fire
  events.

### 7.3 Popstate

`handlePopstate` classifies the entry:

| Case | Handling |
|---|---|
| Same page as the one rendered (fragment entry) | `hashOnly`: restore the saved scroll or scroll to the fragment. No request. A running navigation that has not swapped yet is cancelled; one that already rendered its page runs on to completion (adapters, focus, analytics, `wp_ajax_load_complete`). |
| Entry pushed by another script for the page that is rendered (infinite scroll, filters) | `foreign`: same as above, no request. The engine wraps `history.pushState`/`replaceState` to observe such calls; arguments, return values and exceptions pass through unchanged. |
| Engine inactive | Full load of the URL (`replace`). |
| Anything else | `wp_ajax_load_popstate`, then a `popstate` navigation (direction from `idx`). |

WordPress Interactivity: `wpInteractivityId` in `history.state` is copied into every entry the
engine pushes (`DOCUMENT_STATE_KEYS`), because `@wordpress/interactivity` reloads the page on a
popstate to an entry whose id differs from the document's. Pages that load the Interactivity
**router** do not start the engine at all.

### 7.4 Scroll

After the swap (`applyScroll`): popstate → saved position, else the fragment target, else top.
Push/replace → fragment target (`scrollIntoView`, honours `scroll-margin`), else
`scroll.behavior`: `top` (default), `content` (scroll to the container only if its top is above the
viewport) or `none`. Scrolling always uses `behavior: 'instant'` to ignore `scroll-behavior: smooth`.
A restored position is applied once more after scripts ran if the page grew and the user has not
scrolled. On a reload or a Back that loads a full document, the initial entry's saved position is
re-applied after `load` unless the user scrolled first.

### 7.5 Back/forward cache

No `unload`/`beforeunload` listener exists anywhere. `pagehide` cancels the running navigation and
saves the scroll position; `pageshow` with `persisted` resets loader, transitions, busy state and
the rendered URL. When the page was frozen while the current entry's page was not rendered yet (a
Back/Forward load still running, superseded by a click that was still loading or handed to the
browser, or stopped; `history.isPending()`) and is restored on that entry, the entry is rendered
again with a popstate navigation (its saved scroll position is restored). That navigation starts
after every `pageshow` listener ran, so the loader's and the transitions' own resets do not remove
its indicator and leave transition. Other mismatches (a foreign `pushState`) only relabel the
rendered URL. A request aborted on `pagehide` is never reused by the restored page.

---

## 8. Metadata sync

`metadata.js` `update(newDoc, url)`, called during the swap (step 11c).

- **Title** – `document.title` from the parsed document (set by the engine before `pushState`; the
  manager only re-applies it when different).
- **Head tags** – the managed selector list is `seo.headSelectors` from PHP (filter
  `ajax_loading_head_selectors`), falling back to the built-in list when empty. Groups:
  - metadata (`seo.metadata`): description, keywords, author, robots, googlebot, bingbot,
    `og:*`, `article:*`, `product:*`, `profile:*`, `book:*`, `fb:*`, `twitter:*` (matched by `name`
    and by `property`), oEmbed and JSON alternates, titled RSS alternates;
  - canonical (`seo.canonical`): canonical, shortlink, prev, next, `hreflang` alternates;
  - structured data (`seo.structuredData`): `script[type="application/ld+json"]` in the head
    (never executed). JSON-LD printed as a direct `<body>` child (WooCommerce product schema, a
    footer graph) is synced the same way: the live set is replaced by the fetched one at the position
    of the first old one, nothing changes when both are identical. JSON-LD nested inside the content
    arrives with it; JSON-LD nested elsewhere is not touched;
  - the plugin marker is always managed.
  All live matches are removed and all new matches (outside `<noscript>`) inserted at the position
  of the first old one (or after `<title>`), so there are no duplicates and no stale tags. Nothing
  changes when both sets are identical. Tags are matched by selector, never by plugin comments.
- **`<html>`** – `lang` and `dir` are mirrored (set or removed). Other `<html>` attributes and
  classes are not touched.
- **Body classes** – classes of the previous server page that the new page lacks are removed, the new
  page's classes added, classes added by scripts kept. A `no-js`-style server class is never re-added
  when the script counterpart (`js`, `woocommerce-js`, `customize-support`) is present. The server
  set of the first page is read from the marker's `data-body-class`, which an inline script refines
  to the class attribute of the parsed `<body>` before any body script runs (so classes the template
  passes to `body_class( $class )`, writes into the tag or adds with a later filter count too); later
  pages use the fetched `<body>`. Only for a page cached without that attribute is the previous
  server set estimated from the live classes minus a list of typical script-added classes.
- Other `<body>` attributes are only copied in body mode.

---

## 9. Script and style policy

`assets.js`. Planned before the swap, executed after it.

### 9.1 What can run

- Executable: no `type`, a JavaScript MIME type (`text/javascript`, `application/javascript`,
  `module` …). Everything else is never executed (`application/ld+json`, `importmap`,
  `speculationrules`, `text/template`, consent-blocked `text/plain`, optimiser types).
- Never: `nomodule` classic scripts, `data-ajax-loading-skip`, `data-no-ajax`, ids starting with
  `ajax-loading-`, scripts inside `<noscript>`/`<template>`, SVG scripts, classic inline scripts
  that call `document.write`, inline scripts that would redeclare a top-level `let`/`const`/`class`
  name already declared in the window (heuristic: declarations at column 0), and inline tracking
  snippets outside the content (`gtag(`, `fbq(`, `_paq`, `dataLayer.push`, Google Tag Manager,
  Facebook, Hotjar, Clarity …).

### 9.2 Plan

| Where | Script | Runs when | Order |
|---|---|---|---|
| Inside a replaced region | external | its absolute `src` is not loaded yet, or matches an integration re-run pattern | phase 1, in place |
| Inside a replaced region | inline | `scripts.executeContent` (default on) | phase 1, in place |
| Outside | external | not loaded yet, or matches a re-run pattern; `wp-script-module-data-*` JSON of a new module is added first | phase 0, appended to head/body |
| Outside | companion of a **new** external `X-js` | `X-js-extra`, `-js-translations`, `-js-before`, `-js-after` | phase 0 (replaces the live twin if any) |
| Outside | same id as a live script | text changed **and** (`*-js-extra` with `scripts.refreshLocalized`, or an id from `compat.refreshScriptIds()`) | phase 0, replaces the live element |
| Outside | new inline (id not in the live page, or id-less with a text hash never seen) | `scripts.runNewInline` (default on), not a tracking snippet | head: phase 0; body: phase 2 (after region scripts) |
| Outside | already present / already executed | never | – |

If a module is planned and the new page's import map is not a subset of the live one, planning throws
`integration_excluded` (import maps cannot change after modules loaded).

CSP: `planScripts(newDoc, regions, { csp })` reads the fetched response's `Content-Security-Policy`
header (`PageResponse.csp`; the Report-Only header is ignored; cached and prefetched copies keep it)
and the `<meta http-equiv>` policies of the fetched head. For each policy whose effective script
directive (`script-src-elem`, else `script-src`, else `default-src`) lists a nonce or
`'strict-dynamic'`, a script without that policy's nonce is skipped when it is inline or the policy
has `'strict-dynamic'`. Otherwise an external one is inserted without the live nonce (the browser's
host allowlist decides). Without such a policy the plan is unchanged. Hash sources are not evaluated.

### 9.3 Execution

`runScripts(plan)` runs items one after another: a fresh `<script>` copies the attributes except
`async`, `defer` and `nonce`, gets the page's CSP nonce (`meta[name="csp-nonce"]` or the first
non-adopted script with a nonce) only when the fetched page's own CSP allowed it (see 9.2), and
replaces its inert copy (region scripts) or is appended. External scripts
are awaited (`load`/`error`, 5 s timeout each). While scripts run, `document.write`/`writeln` are
replaced by a warning no-op. A plan superseded by a newer navigation stops early. With
`scripts.dispatchDomEvents` (off by default) `DOMContentLoaded` and `load` are dispatched again
afterwards.

### 9.4 Styles

`syncStyles(newDoc)` runs **before** the swap so new content never appears unstyled:

- Considers `link[rel~=stylesheet]` (not `alternate`, not disabled), the async pattern
  `link[rel=preload][as=style][onload]` (converted to a stylesheet), and `<style>`, outside the
  replaced regions (styles inside regions arrive with the content).
- Links are keyed by `id`, else absolute `href`; styles by `id`, else text. Same key and same
  href/text → keep. Same id, different href/text → insert the new one next to the old one; the old
  one is removed after the swap. New key → insert near its neighbours. New links are awaited
  (load/error/`sheet` polling, 3 s).
- Per-page families: when the new page ships a server stylesheet whose id contains numbers
  (`elementor-post-12-css` → family `elementor-post-#-css`), old members of the same family are
  removed after the swap. With `scripts.removeStaleStyles` (off by default) every server stylesheet
  (`*-css`, `*-styles`) the new page does not use is removed too. `data-ajax-loading-persist` and the
  plugin's own styles are never removed.
- Id-less `<style>` elements (WP 6.0 `.wp-container-N` CSS, WPBakery custom CSS …): the id-less
  styles present at start-up (none on a late start), and after each finalized swap the destination
  page's set, are tracked. Those the new page does not ship outside its regions are removed after the
  swap, whatever `removeStaleStyles` is set to, except a rule for a `.wp-container-N`/`.wp-elements-*`
  class that a preserved element outside the regions still carries (a widget area rendered after the
  content, such as Twenty Twenty-One's footer widgets), while the new page does not use that class
  itself. Styles scripts inject later, CSS-in-JS styles
  (`data-emotion`, `data-styled`, `data-jss`), the plugin's own and `data-ajax-loading-persist` ones
  are never removed.
- `finalizeStyles()` performs the removals after the swap; a sync whose navigation was aborted before
  the swap is rolled back by the next sync.

---

## 10. Request layer

`request.js` `fetchPage(url, { signal, purpose, useCache, fresh })`:

- `fetch(url without fragment, { method: 'GET', mode: 'same-origin', credentials: 'same-origin',
  redirect: 'follow', cache: fresh ? 'no-cache' : 'default', headers: { Accept:
  'text/html,application/xhtml+xml' }, priority: 'high' | 'low' (prefetch) })`. No custom headers,
  no `X-Requested-With`.
- Timeout via the navigation's `AbortController` (`timeout` setting, 2–60 s, default 10 s).
- **Same origin**: `mode: 'same-origin'` turns a cross-origin redirect into a network error
  (`cross_origin_redirect`, or `network` when offline); an opaque response or a final URL on another
  origin is also `cross_origin_redirect`.
- **Status**: 2xx, or 404 when `handle404` is on (WordPress 404 pages are rendered with AJAX);
  anything else → `http_status` (countable for 5xx).
- **Type**: `Content-Disposition: attachment` or a non-HTML `Content-Type` → `invalid_content_type`.
- **Redirects**: `response.url` is the final URL; `redirected` is true when the page key changed.
- **Charset**: the body is read as bytes and decoded with the BOM, else the `Content-Type` charset,
  else a `<meta charset>` pre-scan of the first 1024 bytes, else UTF-8 (`unsupported_charset`,
  `parse_error`).
- **Parsing**: head `<noscript>` blocks are removed (scripting is off in `DOMParser` documents, so
  they would break head parsing), then `DOMParser.parseFromString(html, 'text/html')`. Under Trusted
  Types enforcement a policy named `ajax-loading` is created on first failure. A `<base href>` with
  the response URL is inserted when the page has none, so relative URLs resolve correctly.
- **Marker check** happens in `navigation.js` `validate()` (section 4.3).
- **In-flight sharing**: one request per page key; a navigation adopts a running prefetch
  (`cacheStatus: 'prefetch'`), other prefetches are aborted when a navigation starts.
- **Personalisation**: the marker's `data-personalized`, the `personalizedCookies` prefixes
  (`woocommerce_items_in_cart`, `woocommerce_cart_hash`, `wp-postpass_`, `comment_author_`,
  `wordpress_logged_in_`, `wp_woocommerce_session_`) and `isLoggedIn` disable caching and
  prefetching.

---

## 11. Cache and prefetch

### 11.1 Cache (`cache.js`)

- In memory only (a `Map` in the page), never persisted, never shared between tabs or visitors.
- Off by default (`cache.enabled`). Limits: `maxPages` (1–50, default 10), `ttl` (10–3600 s, default
  300), `maxBytes` (256 KB–20 MB, default 4 MB; size estimated as `html.length × 2`). LRU eviction.
- Stored only when: not logged in, status 200, no `no-store`/`private`/`no-cache`, not redirected to
  another page, no nonce parameter, not excluded, not personalised (cookie, current page or response
  marker) and the response marker is not `bypass`.
- The marker's `data-cache` version empties the cache when it changes (admin "Clear navigation
  cache", import and reset bump it). It is read from pages fetched from the server, so a browser
  discards its cached pages the next time it loads a page from the server (cache hits alone do not
  check it), or when the TTL ends.
- Prefetched responses are stored as `prefetch` entries: with the cache off they are one-shot and
  live 30 s; with the cache on they become normal entries after their first use.
- `reload()` bypasses the cache (`fresh`).

### 11.2 Prefetch (`prefetch.js`)

- Off by default. Triggers: `hover` (pointer over a link for `hoverDelay`, default 80 ms; cancelled
  on leave; a primary `pointerdown` prefetches immediately), `focus`, `touch` (immediate), `viewport`
  (one `IntersectionObserver`, at most `viewportLimit` links per page, scheduled when idle after
  `load`).
- Gates: enabled, engine active, not logged in, page visible, online, not `Save-Data` and not
  `slow-2g`/`2g` when `networkAware`, no navigation running, link passes `checkLink` and
  `checkUrl(…, { purpose: 'prefetch' })`, not `rel=nofollow`, no prefetch opt-out, not already cached
  or in flight, not failed in the last 60 s. On pretty-permalink sites URLs with a query string are
  never prefetched (`*?*` pattern, like core speculative loading).
- Concurrency `maxConcurrent` (1–6, default 2) with a small priority queue that is dropped when a
  navigation starts or the page is hidden.

---

## 12. Failure handling

### 12.1 Error codes

`failures.js` `ERROR_CODES` (also accepted by `POST /log`):

| Code | Raised by | Counts towards the breaker | Full load goes to |
|---|---|---|---|
| `network` | Fetch failed while offline (not counted); body could not be read (counted). A fetch that fails while online is reported as `cross_origin_redirect` (see below). | see left | requested URL |
| `timeout` | Request exceeded `timeout` | yes | requested URL |
| `http_status` | Status not 2xx (and not an AJAX-rendered 404) | only 5xx | final URL |
| `invalid_content_type` | Not HTML, or a download | no | final URL |
| `parse_error` | Empty, undecodable or unparsable response | yes | final URL |
| `unsupported_charset` | Unknown charset label | yes | final URL |
| `marker_missing` | No `<meta name="ajax-loading">` | no | final URL |
| `bypass` | Marker `bypass`, a marker of another site (`data-site`), or a sensitive WooCommerce/membership/BuddyPress body class | no | final URL |
| `container_mismatch` | No content container on both pages | no | final URL |
| `layout_mismatch` | Different layout, header or footer | no | final URL |
| `cross_origin_redirect` | Redirect to another origin; also any `fetch` network failure while the browser reports being online (a same-origin `fetch` cannot tell them apart) | no | requested URL |
| `excluded_redirect` | Redirect to an excluded URL | no | redirect target |
| `integration_excluded` | Adapter verdict, `exclude` mode markers, incompatible import map | no | final URL |
| `interactive_content` | `[data-wp-interactive]` in new content (`interactiveBlocks: reload`) | no | final URL |
| `language_changed` | `<html lang>` changed | no | final URL |
| `asset_changed` | Plugin version or tracked assets changed | no | final URL |
| `prevented` | `wp_ajax_load_before_replace` prevented (emits CANCEL, not ERROR) | no | final URL |
| `exception` | Any other error, including one after the swap started | yes | final URL |
| `circuit_open` | Only used in the report sent when the breaker opens | – | – |
| `device_disabled`, `personalized` | Defined and accepted by the REST endpoint, not raised by 2.0.0 | – | – |

### 12.2 Circuit breaker

Per browser tab (`sessionStorage` keys `ajxl:failures` and `ajxl:breaker`, mirrored in memory):

- `threshold` countable failures (2–20, default 3) within **5 minutes** open the breaker for
  `minutes` (1–1440, default 30). While open, the engine does not start on new page loads and stops
  handling navigations on the current one.
- A successful navigation clears the failure window. The stored "until" time is never trusted beyond
  the configured duration: a later value (clock change, tampering) is reset to now + `minutes`.
- Setting `auto_disable` off disables the breaker.

### 12.3 Logging beacon

When "Record navigation errors" is on, `restLogUrl` is set and each failure (plus the breaker
opening) is reported with `navigator.sendBeacon` (`fetch` with `keepalive` as fallback) as
`{ code, path, status, message, nav }`: the path only (no query, no fragment), a scrubbed message of
at most 200 characters, at most 10 reports per page. The server validates, rate-limits and stores it
(see [developer-guide.md § REST endpoints](developer-guide.md#6-rest-endpoints)).

---

## 13. Accessibility, loader and transitions

- **Live region** `#ajxl-announcer` (`aria-live="polite"`, `aria-atomic="true"`, visually hidden)
  exists from start-up. Messages are cleared first and set 100 ms later. "Loading…" is announced only
  if the loader actually became visible; after the swap "Navigated to {title}" is announced (setting
  `a11y.announce`).
- **Busy state**: `aria-busy="true"` on the content container during the navigation (in body mode on
  the page's first `main`/`[role="main"]`, never on `<body>`/`<html>`, and on nothing when that
  element contains the live region, so the live region is never inside a busy subtree). It is
  cleared after the scripts, adapters and the enter transition, before form values are restored,
  focus moves and "Navigated to …" is announced (step 14), not when the loader finishes.
- **Focus** (`a11y.focus`: `heading` default, `container`, `none`), only when focus is idle or still
  on the element that started the navigation: fragment target → first `[autofocus]` in the new
  content → first `h1` (heading mode) → the container (`main`/`[role="main"]`/body in body mode).
  A temporary `tabindex="-1"` is removed on blur; `focus({ preventScroll: true })`.
- **Loader** (`loader.js`): its own elements only, mounted on `<body>`, `aria-hidden`, shown after
  `loader.delay` (default 150 ms) and kept at least `loader.minDuration` (default 250 ms). An
  indicator that has not appeared by `wp_ajax_load_replaced` never appears; other types start hiding
  then, while the bar jumps to 80 % and completes when the navigation finishes. The elements are
  removed once hidden, and immediately on errors and bfcache restores. Never restyles
  `html`/`body`. The skeleton covers the first content container below the fixed/sticky top chrome
  (admin bar, sticky header: `--ajxl-sticky-top`); a passive capture-phase `scroll` listener
  (throttled to one animation frame) re-measures that chrome and clips the part of the skeleton
  that scrolls under it (`clip-path: inset(…)`), until the skeleton is removed. The overlay caption
  sits on an opaque plate in `--ajxl-bg`, so its contrast does not depend on the veil's opacity.
- **Transitions** (`transitions.js`): classes on the replaced containers only (opacity and transform);
  in body mode the direct children of `<body>` with opacity only; header/footer only with
  `transition.headerFooter`. Skipped for `none`, duration 0, reduced motion (when respected) or a
  hidden page. Horizontal slides (`slide-left`/`slide-right`) never scroll the page sideways: when no
  ancestor already clips horizontal overflow, `.ajxl-clip-x` (`overflow-x: clip`, no scroll container,
  `position: sticky` unaffected) is set on one ancestor (the nearest one spanning the viewport width,
  else the outermost one below `<body>`). An `overflow-x: auto` ancestor met first gets
  `.ajxl-clip-x-scroll` (`overflow-x: hidden`) instead, which keeps it a scroll container (clip would
  undo that when `overflow-y` is not set). A container that is a direct child of `<body>` fades
  instead. Everything is cleaned up after the enter transition,
  on cancel and on bfcache restore.

---

## 14. Server side

### 14.1 Bootstrap and settings

- `ajax-loading.php` loads the classes and calls `run_WP_Ajax_loading()` → `WPAJXL_Core::run()`.
- `WPAJXL_Settings::maybe_upgrade()` on `plugins_loaded` priority 5 (reads the version and settings
  options, both autoloaded, and compares versions): fresh install (no option, no version) → default settings (enabled); 1.x value
  (no `settings_version`, or one below 2) → migrated, also when the version option is already current
  (settings saved by 1.x after a rollback) (`enable_plugin` kept exactly, `disable_emojis` and
  `legacy_jquery` switched on, the rest default); version option `ajax-loading` updated when it is
  older (never downgraded); `ajax_loading_upgraded` fired. A missing settings
  option is never re-created once a version exists; a missing option or a missing `enable_plugin`
  reads as **off**.
- `effective()` = `all()` (stored + `ajax_loading_settings`) with the **safe mode** overrides:
  prefetch off, cache off, no additional GET forms, header/footer preserved, no re-dispatched DOM
  events, no stale-style removal, interactive blocks `reload`, body mode → auto, and Elementor, Divi,
  Beaver Builder and Bricks forced to `exclude` (unless `off`).
- **Pause**: `pause_until` timestamp (admin quick action 1 h or 24 h; API up to 30 days).
  `is_enabled()` is `enable_plugin === 'on'` and not paused.

### 14.2 Does the engine run? (`WPAJXL_Frontend`)

`is_active_for_request()` (only after the `wp` action, memoised):

1. Plugin enabled and not paused.
2. Not admin, AJAX, cron, REST or JSON request; not feed, embed, robots, trackback, favicon; not the
   Customizer preview; not AMP (official AMP plugin, AMP for WP); not login/signup/activation/register;
   not a page-builder editor or preview (Elementor, Beaver Builder, Divi, Bricks, Oxygen, Breakdance,
   Brizy, Thrive, WPBakery, SiteOrigin, Avada, Flatsome flags and APIs).
3. **Visibility**: `everyone`, or `admins` → only users with `manage_options`.
4. Filter `ajax_loading_enabled`.

Then `page_state()`: assets present (`assets/dist/frontend(.min).js/.css`)? Bypass page
(`WPAJXL_Exclusions::is_bypass_request()` + `ajax_loading_bypass`)? Marker `bypass` and nothing
enqueued; otherwise marker `enabled`, script, stylesheet and inline configuration. A configuration
that cannot be JSON-encoded makes the page `bypass`. Any exception → no marker, nothing enqueued.

**Bypass decisions** (`is_bypass_request`, any exception → bypass): previews, password-protected
singular content, excluded post types (singular and archive), WooCommerce cart/checkout/account
pages and endpoints and pages containing the cart/checkout blocks or the cart/checkout/my-account
shortcodes (with WooCommerce exclusions on), Paid Memberships Pro / Ultimate Member / MemberPress
core pages, MemberPress product/group pages, BuddyPress register and activation pages (membership
exclusions on), BuddyPress pages (integration `exclude`).

**Assets**: script handle `ajax-loading-WPajaxLoad` (kept from 1.x), in the footer with
`strategy: defer`, dependency `jquery` only when `legacy_jquery` is on; style handle `ajax-loading`;
inline "before" script with `window.ajaxLoadingConfig` and the `reload_helper` snippet; the marker
and `reload_helper` are also printed at `wp_head` priority 1, on `enabled` and on `bypass` pages
(bypass pages also keep jQuery when `legacy_jquery` is on, like 1.x). With `SCRIPT_DEBUG` the
unminified files are used (version suffixed with the file time).

**Marker**: `<meta name="ajax-loading" content="enabled|bypass" data-version="…" data-cache="…"
data-site="…" [data-personalized="1"] data-body-class="…">`. `data-site` is
`substr( md5( get_current_blog_id() . '|' . untrailingslashit( siteurl ) ), 0, 12 )`
(`WPAJXL_Frontend::site_id()`); `data-body-class` is `implode( ' ', get_body_class() )`. On `enabled`
pages an inline script (`ajax-loading-body-class-js`) printed right after the marker replaces it with
the class attribute of the parsed `<body>` (a `MutationObserver` that fires before the first body
script), so classes the template passes to `body_class( $class )`, hard-coded classes and classes of
a `body_class` filter added after `wp_head` priority 1 are included. It does nothing when it runs
after `<body>` exists (JavaScript delayed by an optimiser); the PHP value then stays.

### 14.3 Exclusions generation (`WPAJXL_Exclusions::for_config`)

`urls` = system patterns (wp-admin, wp-includes, wp-content, plugins, themes, uploads, core entry
points such as `wp-login.php`/`wp-comments-post.php`/`xmlrpc.php`, REST prefix and `rest_route=`,
feeds and embeds, sitemaps, robots, favicon, `.well-known`, `/cdn-cgi/`, `nonce=`, `?action=`,
`&action=`, previews, Customizer parameters, `replytocom=`, `unapproved=`, `moderation-hash=`,
`doing_wp_cron`) + WooCommerce pages, `wc-api`/`wc-ajax` and GET action arguments (when on) +
membership/auth URLs (when on) + excluded post types + the user's lines. `selectors` = opt-out
markers, admin bar, comment reply links, Elementor actions and lightbox, common lightbox scripts,
Interactivity router links, language switchers (multilingual not `off`), `#buddypress a`
(BuddyPress `exclude`), WooCommerce action links, membership selectors, the user's lines, filtered
by `ajax_loading_exclude_selectors`. `prefetch` = the user's lines plus `*?*` on pretty-permalink
sites. `woocommerce` and `membership` are the two protection switches (`exclude_woocommerce`,
`exclude_membership`) as booleans; the engine checks the destination's body classes only when they
are on. Finally `ajax_loading_exclusions` filters the whole array (booleans returned by the filter
are honoured, other values for those two keys are ignored).

### 14.4 Other switches

- **Speculative loading** (WordPress 6.8+): `wp_speculation_rules_configuration` returns `null` on
  pages where the engine runs when `disable_core_speculation` is on (default), filter
  `ajax_loading_disable_core_speculative_loading`. Pages that load normally keep it.
- **Emoji removal**: with `disable_emojis` on (default off; on for sites migrated from 1.x) the emoji
  detection script, styles, feed filters and the emoji DNS-prefetch hint are removed on the front end
  while the plugin is enabled.
- **Admin**: Settings API (`register_setting( 'ajax_loading', '_wp_ajax_loading', sanitize_callback
  )`) for the tabs; quick actions (`toggle_enable`, `toggle_safe_mode`, `toggle_visibility`, `pause`,
  `resume`, `clear_cache`, `reset`, `import`, `export`, `clear_log`) through
  `admin-post.php?action=ajax_loading_action` (POST only, nonce `ajax_loading_admin_action`,
  `manage_options`), redirecting back with a whitelisted notice code. When a page cache is detected
  (`WP_CACHE` or a known page-cache plugin, `WPAJXL_Compat::has_page_cache()`), the notices of
  switches that change the front end, and the notice after a settings save, add that the page cache
  must be purged.
- **Uninstall** removes `_wp_ajax_loading`, `ajax-loading`, `_wp_ajax_loading_log`,
  `_wp_ajax_loading_diagnostics` and the plugin's transients on every site.

---

## 15. Security model

**Trusted**: same-origin HTML that the browser would load and execute on a normal navigation to the
same URL. The engine runs that page's scripts according to section 9, and no others.

**Not trusted / never executed**: responses from another origin (blocked by `mode: 'same-origin'`),
non-HTML responses, downloads, script types that are not JavaScript, `<template>`/`<noscript>`
content, JSON data blocks.

Client side:

- Parsing uses `DOMParser` (inert); there is no `innerHTML` of response strings into the live page
  and no regular-expression rewriting of HTML. Script-context data stays inside `<script>` elements
  (the 1.x XSS amplification is gone).
- URLs carrying nonces are never AJAX-loaded, cached or prefetched; error reports carry only paths.
- The analytics callback is resolved by dotted path without `eval`, rejecting `__proto__`,
  `prototype` and `constructor`. The build fails if a minified bundle contains `eval(` or
  `new Function(`.
- Dynamically inserted scripts reuse the page's CSP nonce only when the fetched page's own CSP
  (response header or `<meta http-equiv>`) allows them; scripts a direct load would block are never
  run. The inline configuration and the
  `reload_helper` snippet are inline scripts (sites with a strict CSP must allow them, for example
  through WordPress' inline script attribute filters).
- Form snapshots never contain sensitive fields; the cache is memory-only.

Server side:

- Output escaping: configuration via `wp_json_encode( …, JSON_HEX_TAG | JSON_HEX_AMP )`; marker
  attributes with `esc_attr`; admin output escaped; every PHP file with code starts with
  `defined( 'ABSPATH' ) || exit;` (the `index.php` stubs only contain a comment).
- Admin: `manage_options` for the page, Settings API with a sanitising callback, nonce + capability
  + POST for quick actions, import limited to 100 KB and validated.
- REST: admin routes need `manage_options`; the public `POST /log` is refused unless error logging
  is on, accepts only whitelisted codes and navigation types, root-relative paths reduced to path
  characters, a scrubbed message (no query strings, e-mail or IPv4 addresses), bodies ≤ 4 KB, and a
  global limit of 30 entries per 10 minutes; entries live in a non-autoloaded 50-entry ring buffer.

---

## 16. Known limitations

Collected from the code and its comments:

- **Interactivity API**: new content containing `[data-wp-interactive]` loads normally by default
  (`interactive_blocks: reload`), because WordPress cannot start those blocks again; `replace` swaps
  them without starting them. Pages that load the Interactivity router (for example a Query Loop with
  enhanced pagination) do not start the engine at all.
- **Import maps** cannot change once modules loaded: a destination needing other mappings loads
  normally.
- **Scripts written for a single page load**: inline scripts redeclaring top-level `let`/`const`/
  `class` (column-0 heuristic) and `document.write` scripts are skipped; plugins that only boot on
  `DOMContentLoaded` are handled by adapters or forced to full loads (see compatibility.md).
  `dispatchDomEvents` re-fires `DOMContentLoaded`/`load` for every listener already registered, which
  can double-initialise code.
- **Layouts**: pages whose templates differ (header, footer, ancestors of the content) always load
  normally (`layout_mismatch`), by design.
- **Body classes** are exact through the marker's `data-body-class`, refined in the browser to the
  parsed `<body>`. When that inline script is delayed by a JavaScript optimiser, the PHP value
  (`get_body_class()` at `wp_head`) is used, so a class the template passes to `body_class( $class )`
  on the first page stays until a page that has it is left. Only for a page cached without the
  attribute (an older page-cache copy) is the server class list estimated, so a script-added body
  class not in the built-in list can then be removed on the first navigation.
- **Head sync** covers the managed selectors only. JSON-LD printed as a direct `<body>` child
  (WooCommerce, a Yoast footer graph) is synced with structured data; JSON-LD nested elsewhere outside
  the content area is not updated; other `<html>` attributes and classes are not synced.
- **CSP hashes**: under a nonce-based CSP, scripts allowed only by a hash are not run after an AJAX
  navigation.
- **Double requests** remain in two cases: a failed navigation (for example a 5xx response) is
  followed by a normal load of the same URL, and a redirect to another origin is requested once by
  `fetch` and again by the full load. A page that is only excluded through its marker (`bypass`) is
  fetched before it is loaded normally; URL exclusions avoid this.
- **Circuit breaker** state is per tab (`sessionStorage`), not per site.
- **jQuery-dependent adapter steps** (WooCommerce gallery/variation/sorting re-init, Divi, Beaver
  Builder, WPForms `ready()`, Fluent Forms, the `post-load` event, Gravity Forms' jQuery event,
  Elementor's `attachDocumentClass` path) are skipped when the page has no jQuery.
- **Scroll positions** for entries the engine did not tag are keyed by their full URL in
  `sessionStorage` (at most 50 entries).
- **Caching/prefetch** are off by default and never used for logged-in or personalised visitors.
- **Browsers** without `Element.replaceChildren` and the other APIs in section 3 keep normal
  navigation.
- **WordPress 6.0–6.2** has no script loading strategies, so the engine prints in the footer without
  `defer` there (it still waits for `DOMContentLoaded`, and records the server structure and id-less
  styles when parsing ends, see [3](#3-engine-start-up)).
- **Horizontal slides** keep their slide when a theme clips horizontal overflow only on `<body>`
  (`overflow-x: hidden`); a script that scrolls the page horizontally during the slide can then
  offset it briefly.
- **Late start**: when the engine starts after the page finished loading (JavaScript delayed by an
  optimiser), elements scripts inserted before cannot be told apart from server markup, so the
  layout check uses the older, more tolerant contiguous-run comparison and id-less `<style>` elements
  present at start-up are never removed.
