# AJAX Loading 1.1 – audit report and 2.0 resolution

This report summarises the independent audit of AJAX Loading **1.1** that preceded the 2.0 rewrite,
and records, finding by finding, how **2.0.0** addresses it. Every "2.0" statement was checked
against the 2.0.0 source; file references point to that code. Where 2.0 does not fully solve a
finding, the report says so.

Contents

1. [Summary](#1-summary)
2. [Method](#2-method)
3. [Findings: bugs and failure modes](#3-findings-bugs-and-failure-modes)
4. [Findings: security](#4-findings-security)
5. [Findings: coding standards and plugin review](#5-findings-coding-standards-and-plugin-review)
6. [Findings: performance](#6-findings-performance)
7. [How 2.0 addresses each finding](#7-how-20-addresses-each-finding)
8. [What 1.x behaviour had to be preserved](#8-what-1x-behaviour-had-to-be-preserved)
9. [Open items](#9-open-items)

---

## 1. Summary

AJAX Loading 1.1 intercepted internal links with jQuery and History.js, fetched the target page and
replaced the whole `<body>`.

The central defect: the configured container was `body`, but the HTML pre-processing renamed every
`<body>` tag to a `<div>`, so the container lookup always came back empty and the engine fell back
to inserting the **entire fetched document** – head, stylesheets, metadata and every script as inert
text – into the live `<body>`. As a consequence no page script ever ran after an AJAX navigation,
the head was never updated, and most of the other defects below followed.

| Severity | Count | Headlines |
|---|---|---|
| Critical | 1 | No page script runs after an AJAX navigation; the whole document is pushed into `<body>`. |
| High | 8 | XSS amplification; non-HTML links freeze the page; cross-page `#hash` links load nothing; some in-page hashes redirect to a 404; non-ASCII slugs garbled; `return false` blocks theme/plugin handlers; head never synced; Customizer preview broken. |
| Medium | 15 | Missing capability check on settings save; `site_url`/prefix-match bug; protocol-relative links; redirects; race conditions; layout-breaking injected CSS; scroll; `.header` overwrite; SVG titles; analytics; undeclared jQuery; settings re-enabling themselves … |
| Low / info | 18 | Fake nonce check, CSP issues, `eval` in a dead polyfill, escaping and i18n misuse, readme errors, dead files, no uninstall step … |

**2.0 status**: 2.0 is a rewrite (vanilla JavaScript engine, new PHP classes) that keeps the 1.x
public surface. Of the 42 numbered findings (B1–B30, S1–S12), 38 are fixed, 3 are partly fixed
(B12, B26, S4) and 1 is unchanged on purpose (S11). One review item is still open: unused 1.x
images remain in `admin/assets/img/` (see [section 9](#9-open-items)). The 1.x analytics hooks that
the audit listed under "do not preserve" are still called by 2.0, as harmless no-ops unless those
globals exist.

---

## 2. Method

- **Scope**: every file of the 1.1 trunk – `ajax-loading.php`, `includes/*.php`, `admin/*.php`,
  `includes/loadingJs/WPajaxLoad.js`, `admin/assets/styles/standalone.css`, `readme.txt` and images.
- **Static review** of all files.
- **Runtime verification**: the unchanged 1.1 `WPajaxLoad.js` was run in headless Chrome with
  jQuery 3.7.1 (the version WordPress bundles) from a local test harness. Findings reproduced there
  are marked *verified*; findings derived by following the code path are marked *traced*.
- **Resolution check (this report)**: each finding was compared with the 2.0.0 source in
  `assets/src/js/frontend/`, `includes/` and `admin/`; references below use those paths.

---

## 3. Findings: bugs and failure modes

| ID | Severity | Area | Finding | Impact |
|---|---|---|---|---|
| B1 | Critical | Scripts | After an AJAX navigation no page script runs (verified). The script branch only looked inside an always-empty container. | Checkout/payment JS, form plugins, reCAPTCHA, sliders, Elementor widgets, comment reply, lazy loaders and maps do not work on AJAX-loaded pages. |
| B2 | High | DOM / head | The whole fetched document, `<head>` included, is injected into `<body>`; the head is never synced (verified). | Duplicate stylesheets and IDs, stale page CSS, wrong `<html lang>`, extra wrapper breaking selectors, body classes applied twice. |
| B3 | High | Links | Non-HTML "internal" links (PDF, ZIP, images, feeds, `/wp-json/`) throw inside the success handler and leave the page at 40 % opacity with clicks disabled (verified). | The page is unusable until Back or reload. |
| B4 | High | History | Cross-page `#hash` links change the URL but load nothing (verified). | Wrong content under the new URL. |
| B5 | High | History | In-page hashes containing `.`, `/` or `?` (`#v1.2`, `#!/x`) are turned into page URLs and AJAX-loaded, ending on a 404 (verified). | Anchor links and hash routers break. |
| B6 | High | URLs | Non-ASCII permalinks are decoded with `unescape()` (Latin-1) and re-encoded wrongly (verified). | Every non-English slug and any `%25` URL leads to a 404. |
| B7 | High | Links | Per-link handlers return `false`, stopping propagation; links without `href` count as internal (verified). | Theme menu toggles, WooCommerce AJAX add-to-cart, cart removal and lightboxes stop working; `?add-to-cart=` can be replayed. |
| B8 | High | Context | The engine runs in the Customizer preview (traced); page-builder previews and AMP are exposed too. | Customizer previews show the live site instead of the draft; invalid AMP. |
| B9 | Medium | Links | Ctrl/Shift/Alt clicks, `target="_blank"`, `download` and already-prevented clicks are taken over (verified for Ctrl). | "Open in new tab" and downloads break; mobile menus that prevent the first tap break. |
| B10 | Medium | URLs | `site_url()` instead of `home_url()`, and a string prefix match on it (verified). | Subdirectory installs get no AJAX navigation; look-alike hosts or ports leave History.js permanently busy and every link dead. |
| B11 | Medium | URLs | Protocol-relative external links (`//host/x`) are rewritten to the own origin (traced). | 404 followed by a full load of the wrong URL. |
| B12 | Medium | Requests | Redirects are ignored: the pre-redirect URL stays in the bar and history; cross-origin redirects are requested twice (traced). | Nonce'd action URLs replayable on refresh; logout via XHR; double counts on click trackers. |
| B13 | Medium | Requests | No abort or sequence check for overlapping navigations (traced). | Slower earlier response shows its content under the newer URL. |
| B14 | Medium | Links | Click handlers bound twice after each navigation (verified). | Doubled work, fragile code. |
| B15 | Medium | DOM | Every `.header` element is overwritten with the first one (verified logic). | Card headers show the site header. |
| B16 | Medium | DOM | `<title>` inside inline SVG is renamed to `<div>`, breaking the SVG (verified). | Accessible icons disappear. |
| B17 | Medium | Scroll | Every state change jumps to the top; Back never restores; `#hash` targets never scrolled to (traced). | Lost position on long archives. |
| B18 | Medium | Layout | Injected `body{position:relative!important}`, opacity and `top` animations, permanent inline styles on `<body>` (traced). | Broken scroll-lock menus, dimmed fixed headers, overridden theme states, layout-triggering animation. |
| B19 | Medium | Loading | jQuery dependency undeclared; a second History.js copy aborts initialisation (traced). | Optimisation plugins that defer or reorder scripts break the plugin while its CSS still applies. |
| B20 | Medium | Analytics | Only legacy `pageTracker` (ga.js) and Reinvigorate are notified (traced). | GA4, GTM and Matomo count only landing pages. |
| B21 | Medium | Accessibility | Focus lost, no announcement, keyboard not blocked during load (traced). | Screen-reader and keyboard users get no feedback. |
| B22 | Medium | Settings | The plugin enables itself on the first admin request; an `'off'` setting is overwritten if the version option disappears (traced). | Unexpected front-end changes; settings coming back. |
| B23 | Low | Settings | `reload_helper.ids` is applied after binding, so it excludes nothing (traced). | The documented exclusion knob has no effect. |
| B24 | Low | Parsing | A comment before `<html>` makes every navigation fetch and then fully reload (verified). | Two requests per click. |
| B25 | Low | Links | Clicking a link to the current page does nothing (traced). | Logo click on the home page has no effect. |
| B26 | Low | Requests | The error path requests the URL a second time (traced). | Double log entries and double non-idempotent GETs on errors. |
| B27 | Low | Head | The tab title flashes blank during loading (traced). | Visual glitch. |
| B28 | Low | Parsing | Template scripts (`text/html`, `wp.template`) become live DOM (verified). | Real 404 requests for template placeholders; JSON-LD turned into divs. |
| B29 | Low | Admin | No Post/Redirect/Get after saving settings. | Refresh re-posts the form. |
| B30 | Low | Front end | Emoji scripts are removed as an undocumented side effect. | Surprising behaviour change. |

---

## 4. Findings: security

| ID | Severity | Area | Finding | Impact |
|---|---|---|---|---|
| S1 | High | XSS | `<script>` is renamed to `<div>`, so script-context JSON (not `<`-escaped) is parsed as markup, in the live document (verified PoC). | User input echoed into inline JSON executes as HTML on AJAX navigation, e.g. via a crafted search link clicked by an admin. |
| S2 | Medium | Admin | Settings saved on `init` for every admin request with only a generic nonce, no capability check. | Any holder of a matching nonce can change the setting; `wp_die` on unrelated admin URLs. |
| S3 | Low | Admin | Fake nonce check on tab routing (create then verify the same value). | Protects nothing; superglobals not sanitised. |
| S4 | Low | Output | Inline JS config with the wrong escaping function; inline `<script>`/`<style>` and `onclick` attributes. | Needs `unsafe-inline` under CSP. |
| S5 | Low | Code | `eval` in the bundled JSON polyfill. | Flagged by scanners and reviewers. |
| S6 | Low | Output | Unescaped settings-link URL, concatenated tab links, `target="_blank"` without `rel="noopener"`, `esc_attr` in HTML context. | Hardening gaps. |
| S7 | Low | Files | No direct-access guards; dead legacy files with unescaped output. | Plugin Check failures. |
| S8 | Low | Lifecycle | `update_option` can run on any admin GET by any role before `init`. | Unexpected state changes. |
| S9 | Low | Admin | Admin CSS with global class names on every admin screen. | Style collisions with other plugins. |
| S10 | Low | Privacy | History.js persists every visited URL and title (including nonce URLs) in `sessionStorage`. | Sensitive URLs kept client side. |
| S11 | Info | Privacy | The About tab loads Gravatar and `ps.w.org` images. | Admin IP sent to third parties when the tab is opened. |
| S12 | Info | Rewrite hazard | A rewrite that runs scripts without checking `type` would run consent-blocked `text/plain` scripts. | GDPR risk to avoid. |

---

## 5. Findings: coding standards and plugin review

| ID | Finding |
|---|---|
| R1 | Reserved `wp_` prefixes (`WP_` classes, `_wp_ajax_loading`, `wp_ajax_load_complete`) and generic globals (`reload_helper`, `History`). |
| R2 | `<style>`/`<script>` echoed in `wp_head` instead of the enqueue/inline APIs; enqueueing inside `wp_head`. |
| R3 | `wp_register_script(..., 'all')` (a media value as `in_footer`), dependency missing, odd `../` path. |
| R4 | Plugin action link basename hard-coded. |
| R5 | i18n: HTML in strings, missing text domain, untranslated strings, wrong escaping helpers, dead files with another text domain. |
| R6 | Functions that echo and return `null`, then `echo wp_kses_post( null )`. |
| R7 | Closing `?>` tags with CRLF endings; mixed indentation; loose comparisons; `@` suppression; unsanitised superglobals. |
| R8 | Settings API not used; checkbox label not tied to its input. |
| R9 | No activation hook, no `uninstall.php`; options autoloaded forever. |
| R10 | Plugin header missing `License`, `License URI`, `Requires at least`, `Requires PHP`. |
| R11 | `readme.txt`: wrong zip name, wrong menu location, unsupported "Tested up to", very old minimums, non-standard sections, no FAQ/upgrade notice, vague changelog. |
| R12 | Dead code and assets shipped (duplicate classes, unused images, ~60 % unused CSS, dead JS). |
| R13 | Invalid selector `.tab-section .field:first` in the admin CSS. |

---

## 6. Findings: performance

| ID | Severity | Finding |
|---|---|---|
| F1 | Medium | `unload`/`beforeunload` listeners make every page ineligible for the back/forward cache. |
| F2 | Medium | History.js rewrites its `sessionStorage` store every second for the life of the tab. |
| F3 | Medium | jQuery + jquery-migrate forced onto every page, plus a 48 KB unminified script about 45 % dead. |
| F4 | Medium | Whole body torn down and rebuilt per navigation; response parsed twice; head stylesheets duplicated. |
| F5 | Medium | Per-link binding via a custom pseudo-selector, twice per navigation. |
| F6 | Low | Fixed 1000 ms delay before `wp_ajax_load_complete`. |
| F7 | Low | Layout-triggering `top` animation and a no-op scroll animation per navigation. |
| F8 | Low | Dormant `$.getScript` path with `cache: false`. |
| F9 | Low | Error path requests twice; Back/Forward always refetches; no cache or prefetch. |
| F10 | Low | Admin stylesheet on every admin screen; two option reads per request. |

---

## 7. How 2.0 addresses each finding

Status: **Fixed**, **Partly fixed**, **Unchanged** (deliberately kept) or **Open**.

### 7.1 Bugs

| ID | Status | How 2.0 addresses it |
|---|---|---|
| B1 | Fixed | New pages are parsed inert and their scripts planned and executed by the AssetManager: scripts inside the new content, new external scripts with their WordPress companions (`-js-extra`, `-js-before` …), changed localized data and new inline scripts (`assets.js` `planScripts`/`runScripts`). Integration adapters re-initialise builders and form plugins (`compat.js`). |
| B2 | Fixed | Only the children of the detected content container (and optionally header/footer) are replaced (`content.js` `replace`); managed head tags, `<html lang/dir>` and body classes are synced (`metadata.js`); stylesheets are added before the swap and superseded ones removed after it, including id-less `<style>` blocks of the previous page (`assets.js` `syncStyles`/`finalizeStyles`). |
| B3 | Fixed | File URLs (long extension list, `wp-content/`, `wp-includes/`, `.php` other than `index.php`) are never intercepted (`links.js` `isFile`); feeds, REST and sitemaps are excluded patterns (`class-ajax-loading-exclusions.php` `system_patterns`); a non-HTML `Content-Type` or `Content-Disposition: attachment` aborts to a normal load (`request.js` `validate`). No loading state can be left behind: every failure cleans up and loads the page normally. |
| B4 | Fixed | The requested fragment is kept through redirects and the target is scrolled into view after the swap (`navigation.js` `run`, `applyScroll`). |
| B5 | Fixed | Same-page fragment links are left to the browser (`links.js` `hash-only`); fragment-only history entries are handled as scrolling, not as navigations (`history.js` `handlePopstate` → `hashOnly`). There is no hashchange-to-popstate conversion. |
| B6 | Fixed | URLs are handled with the `URL` API and never decoded or re-encoded (`links.js` header comment); exclusion patterns are tested against raw and decoded paths (`utils.js` `matchesPattern`). |
| B7 | Fixed | One delegated `click` listener on `window`, bubble phase, that never stops propagation and ignores clicks already prevented (`navigation.js` `onClick`); anchors without `href` are ignored; WooCommerce action links and lightbox links are excluded selectors. |
| B8 | Fixed | The engine never runs in the Customizer preview, AMP pages, page-builder editors/previews, admin, REST, feeds or login screens (`class-ajax-loading-frontend.php` `passes_hard_requirements`); the browser also refuses to start when `wp.customize` exists (`index.js` `blockedReason`). |
| B9 | Fixed | Non-primary buttons, Ctrl/Meta/Shift/Alt, foreign `target` (including `<base target>`), `download`, `rel="external"` and `defaultPrevented` are all respected (`links.js` `checkLink`). |
| B10 | Fixed | `homeUrl` is `home_url( '/' )`; same-origin is checked with `URL.origin` and the path must lie inside the home path (`links.js` `evaluate`); `reload_helper.rootUrl` is `home_url()`. History.js and its busy flag are gone. |
| B11 | Fixed | Protocol-relative links resolve with the `URL` API; another origin is left to the browser. |
| B12 | **Partly fixed** | The final URL after redirects is pushed (`request.js` uses `response.url`); a redirect to an excluded URL loads that URL normally (`excluded_redirect`); URLs with nonce parameters, WooCommerce actions and logout links are never fetched. **Remaining**: a redirect to another origin is blocked by `mode: 'same-origin'` only after the server handled the first request, and the fallback loads the original URL again, so the original URL is still requested twice. Excluding such URLs (for example `/go/`) avoids it. |
| B13 | Fixed | One `AbortController` per navigation, a "current navigation" check after every await (`ensureCurrent`), cancellation of superseded navigations with `wp_ajax_load_cancel`, de-duplication of repeated clicks (`navigation.js`). |
| B14 | Fixed | Single delegated listener; nothing is re-bound after navigation. |
| B15 | Fixed | No `.header` copying; headers are preserved, replaced as a whole region, or the navigation falls back (`content.js`). |
| B16 | Fixed | `DOMParser` parses the real document; no tag renaming. |
| B17 | Fixed | Scroll positions per history entry (memory + `sessionStorage`), restore on Back/Forward, fragment targets, configurable behaviour after push navigations, `scrollRestoration = 'manual'` (`history.js`, `navigation.js` `applyScroll`). |
| B18 | Fixed | No styles on `html`/`body` beyond an optional `cursor: progress` hook; the loader uses its own elements; transitions animate only opacity/transform on the replaced containers and clean up completely (`frontend.css`, `loader.js`, `transitions.js`). |
| B19 | Fixed | The engine is vanilla JavaScript with no dependency; jQuery is a declared dependency only when `legacy_jquery` is on; a second copy of the bundle exits immediately (`index.js` `boot`); a late-running engine still starts. |
| B20 | Fixed | Analytics modes `gtag` (GA4 `page_view`) and `datalayer` (GTM event), optional timing events, consent gate, custom callback (`analytics.js`); in mode `none` GA4/GTM history triggers see the engine's `pushState` (the title is set first). |
| B21 | Fixed | Polite live region, `aria-busy` (on the content container, never on `<body>`/`<html>`, cleared before focus moves), focus moved to the fragment target, `[autofocus]`, first `h1` or the container (`a11y.js`, `navigation.js` `run`). |
| B22 | Fixed | Defaults are seeded only by the activation hook (when no settings exist) and by `maybe_upgrade()` when neither the settings nor the version option exist; an ordinary request never re-creates a missing settings option once a version is stored; migration keeps `enable_plugin` exactly, also when 1.x settings were saved again after a rollback (`class-ajax-loading-settings.php` `maybe_upgrade`, `migrate_legacy`). Fresh installs remain enabled by default (preserved behaviour, now through activation). |
| B23 | Fixed | `reload_helper.ids` is read at start-up and added to the exclusion selectors (`index.js` `applyLegacyHelper`). |
| B24 | Fixed | `DOMParser` handles comments and doctypes. |
| B25 | Fixed | A click on the current page re-renders it and replaces the history entry instead of adding one (when that page is also the current history entry; during a pending Back/Forward a new entry is pushed) (`navigation.js` `createNav`). |
| B26 | **Partly fixed** | 404 pages are rendered through AJAX (`handle_404`, default on), so they are requested once. Other failures (for example 5xx, timeouts) still fall back to a normal load of the same URL – a deliberate safety choice. |
| B27 | Fixed | The title changes only at swap time, from the fetched page, before the history entry is written. |
| B28 | Fixed | Only JavaScript MIME types and modules are executed; templates, JSON-LD, `text/plain` and other types stay data (`assets.js` `scriptKind`); `<template>` content is inert. |
| B29 | Fixed | Settings use the Settings API (`options.php` redirects); quick actions go through `admin-post.php` and redirect back with a notice code. |
| B30 | Fixed | Emoji removal is the documented setting **Disable WordPress emoji scripts** (`disable_emojis`): off for new installs, switched on by the 1.x migration. |

### 7.2 Security

| ID | Status | How 2.0 addresses it |
|---|---|---|
| S1 | Fixed | Responses are parsed with `DOMParser` into an inert document (`request.js` `parse`); scripts stay `<script>` elements and are executed only as scripts; no `innerHTML`/jQuery parsing of response strings. |
| S2 | Fixed | Settings are saved through `options.php` (`register_setting` with a sanitising callback, `manage_options`); quick actions require POST, the nonce `ajax_loading_admin_action` and `manage_options` (`admin/class-ajax-loading-admin.php` `handle_action`). |
| S3 | Fixed | Tab routing is read-only: `sanitize_key( wp_unslash( … ) )` against a whitelist (`current_tab`). |
| S4 | **Partly fixed** | The configuration is passed with `wp_add_inline_script` and `wp_json_encode( …, JSON_HEX_TAG \| JSON_HEX_AMP )`; no inline `<style>` and no `onclick` attributes remain; scripts inserted after navigation reuse the page's CSP nonce, but only when the fetched page's own policy (response header or `<meta http-equiv>`) allows them (`assets.js` `planScripts`). **Remaining**: the configuration and the `reload_helper` snippet are still inline scripts, so a strict CSP has to allow them (for example via WordPress' inline-script attribute filters). |
| S5 | Fixed | The polyfills are gone; the build fails if a minified bundle contains `eval(` or `new Function(` (`tools/build.mjs`). |
| S6 | Fixed | Action links use `esc_url`/`esc_html__` and `rel="noopener noreferrer"`; admin views escape their output. |
| S7 | Fixed | Every PHP file with code starts with `defined( 'ABSPATH' ) \|\| exit;`; the duplicate legacy PHP files and the old JS are removed. |
| S8 | Fixed | `maybe_upgrade()` runs on `plugins_loaded`, reads two autoloaded options and writes only when the stored version is older than the code or the stored settings are still in the 1.x format (for example saved by 1.1 after a rollback). |
| S9 | Fixed | Admin assets load only on the plugin's own screen (`enqueue_assets` checks the hook suffix); the admin CSS is scoped to `.ajxl-admin`. |
| S10 | Fixed | History.js is gone. `sessionStorage` holds scroll positions keyed by random entry keys (the URL is used only for entries the engine did not create, at most 50) and optional form values without sensitive fields. Nonce URLs are never AJAX-loaded. |
| S11 | Unchanged | Kept on purpose with the About tab content (preservation item 12): the Gravatar and plugin icons load only when an administrator opens that tab. The front end makes no third-party requests. |
| S12 | Fixed | `text/plain` and every other non-JavaScript type are never executed. |

### 7.3 Coding standards and plugin review

| ID | Status | How 2.0 addresses it |
|---|---|---|
| R1 | Fixed (compat names kept) | New code uses `WPAJXL_*`, `ajax_loading_*` and `ajxl` prefixes. The 1.x names (`_wp_ajax_loading`, `wp_ajax_load_complete`, `reload_helper`, `WP_Ajax_loading_Core`, `WP_ajax_loading_Admin`) are kept for compatibility; the `History` global is removed. |
| R2 | Fixed | Assets enqueued on `wp_enqueue_scripts`; config via `wp_add_inline_script`; `reload_helper` via `wp_print_inline_script_tag`; the marker is a single escaped `<meta>`. |
| R3 | Fixed | `array( 'in_footer' => true, 'strategy' => 'defer' )`, dependency declared only when needed, files under `assets/dist/`. |
| R4 | Fixed | `plugin_basename()` is used for the action-link hook. |
| R5 | Fixed | Strings use the `ajax-loading` text domain and the matching escaping helpers (spot-checked). |
| R6 | Fixed | The admin was rewritten with one template per tab. |
| R7 | Fixed | No trailing `?>`, LF line endings, WordPress coding style, superglobals unslashed and sanitised. |
| R8 | Fixed | Settings API with a sanitising callback; every field has a label. |
| R9 | Fixed | Activation hook (`WPAJXL_Core::activate`) and `uninstall.php` (all options and transients, every site on multisite). The log and diagnostics options are not autoloaded. |
| R10 | Fixed | Header has `License`, `License URI`, `Requires at least: 6.0`, `Requires PHP: 7.4`. |
| R11 | Fixed | `readme.txt` rewritten for 2.0 (current header values, FAQ, Upgrade Notice). Screenshot images live in the SVN `assets/` directory, outside trunk, and were not checked here. |
| R12 | **Open (partly)** | Duplicate classes, the old engine and the old stylesheet are gone. Five unused 1.x images are still in `admin/assets/img/`: `gpay.png`, `paypral.png`, `paytm.png`, `qr.png`, `sbi.png` (no reference in the code). |
| R13 | Fixed | The admin stylesheet was rewritten; the invalid selector is gone. |

### 7.4 Performance

| ID | Status | How 2.0 addresses it |
|---|---|---|
| F1 | Fixed | No `unload`/`beforeunload` listeners; `pagehide`/`pageshow` are used instead. |
| F2 | Fixed | No interval; storage is written on navigation, scroll bookkeeping and `pagehide` only. |
| F3 | Fixed, with a size trade-off | No jQuery requirement (only with `legacy_jquery`, on for migrated sites). The engine is larger than 1.x's script: `frontend.min.js` is 173,129 bytes raw / 59,885 bytes gzip plus 8,282 / 2,242 bytes of CSS (`assets/dist/sizes.json`), loaded deferred in the footer and only on pages where the engine runs. |
| F4 | Fixed | Only container children are replaced; the response is parsed once; stylesheets are deduplicated by id/href. |
| F5 | Fixed | One delegated listener. |
| F6 | Fixed | No delay by default (`legacyCallbackDelay` 0, filterable up to 10 s). |
| F7 | Fixed | Transitions use opacity/transform only; scrolling is instant. |
| F8 | Fixed | Scripts are inserted as `<script>` elements and use normal HTTP caching. |
| F9 | Partly fixed | Optional in-memory page cache and prefetching (both off by default); the error path still loads the URL normally (see B26). |
| F10 | Fixed | Admin assets only on the plugin screen; settings are one autoloaded option. |

---

## 8. What 1.x behaviour had to be preserved

From the audit's preservation list, in priority order, with the 2.0 status.

### P0 – breaking these breaks existing installs

| # | Item | 2.0 |
|---|---|---|
| 1 | Main file `ajax-loading/ajax-loading.php`. | Kept. |
| 2 | Option `_wp_ajax_loading` with `enable_plugin` = `'on'`/`'off'`; missing option = disabled; never overwrite `'off'`. | Kept. 2.0 adds its settings to the same array (`settings_version` 2); a missing option or missing `enable_plugin` reads as off; migration copies `enable_plugin` exactly. |
| 3 | `wp_ajax_load_complete` semantics: bare-identifier lookup at call time, no arguments needed, asynchronous, after swap and title, once per successful navigation, never on the initial load; plus a DOM event; errors caught. | Kept (`events.js` `resolveLegacy`). Differences: the delay defaults to 0 instead of ~1000 ms (filter `ajax_loading_legacy_callback_delay`), and the detail object is passed as an argument. The DOM event `wp_ajax_load_complete` and eight further events exist. |
| 4 | Admin page slug `ajax-loading-admin` under Settings, `manage_options`. | Kept. |
| 5 | Text domain `ajax-loading`. | Kept. |

### P1 – public surface

| # | Item | 2.0 |
|---|---|---|
| 6 | `reload_helper` global with all keys, defined in the head; honour `ids` and `container_id`; `rootUrl` = `home_url()`. | Kept, on every page that carries the marker (also `bypass` pages); `ids` and `container_id` now work; existing values set by site code are not overwritten. |
| 7 | `.no-ajaxy` and the built-in exclusions (`#` links, `wp-login`, `wp-admin`). | Kept and extended (`data-no-ajax`, `.no-ajax`, `data-ajax-loading="false"`, many URL and selector rules). |
| 8 | Body-class swap, title update, scroll to top on forward; restore on Back. | Kept; Back/Forward now restores the position. |
| 9 | jQuery available on the front end for old callbacks. | Setting `legacy_jquery`, switched on by the migration, off for fresh installs; it loads jQuery on pages where the engine runs and on `bypass` pages. |
| 10 | Script handle `ajax-loading-WPajaxLoad` in the footer. | Kept. |
| 11 | Plugin-list links: Settings, then Say Thanks. | Kept. |
| 12 | Admin tabs Settings / About (`&con=about`) / Say Thanks (`&con=donate`) and their content. | Kept (legacy `con` values map to the new tabs; about, other plugins, helpful links, Razorpay/UPI donation content). |
| 13 | Form field name `_wp_ajax_loading[enable_plugin]`. | Kept (Settings API with the same option name). |
| 14 | Version option `ajax-loading`, updated on upgrade. | Kept and now updated by `maybe_upgrade()`. |

### P2 – nice to keep

| # | Item | 2.0 |
|---|---|---|
| 15 | `window.History` + jQuery `statechange` shim. | **Not kept**: History.js is removed; the removal is documented in [migration.md](migration.md). |
| 16 | Constants `WPAJXL_*`, `run_WP_Ajax_loading()`, classes `WP_Ajax_loading_Core`/`WP_ajax_loading_Admin`. | Kept (classes as aliases). |
| 17 | Emoji removal when enabled. | Option `disable_emojis`, on after migration. |
| 18 | Default "enabled" for fresh installs, via activation; never re-seed. | Kept. |
| 19 | Redirect legacy slug `wp-ajax-loading-admin`. | Kept (`maybe_redirect_legacy_slug`). |

### Do not preserve

The `documentHtml` regex, `$( html )` parsing, `.html()` to read scripts, untyped script execution,
History.js, the JSON polyfill and `unescape`, per-link `return false` handlers, the injected body
CSS, the `.header` overwrite: all gone in 2.0. The legacy `pageTracker._trackPageview()` and
`reinvigorate.ajax_track()` calls were listed here too; 2.0 keeps them (as the design asked) but they
only run when those globals exist, next to the new GA4/GTM options.

---

## 9. Open items

| Item | Notes |
|---|---|
| Unused 1.x images (R12) | `admin/assets/img/gpay.png`, `paypral.png`, `paytm.png`, `qr.png`, `sbi.png` are not referenced; remove them from SVN. |
| Double request for cross-origin redirects (B12) | Inherent to `fetch` with `mode: 'same-origin'`; mitigated by excluding redirecting URLs. |
| Second request after a failed navigation (B26, F9) | Deliberate: a normal page load is the safe fallback. |
| Inline scripts under strict CSP (S4) | The configuration must be inline; sites with a strict CSP need to allow it or add a nonce through WordPress' filters. |
| About-tab third-party images (S11) | Kept with the preserved About content. |
