elementor / elementor/one-click-accessibility

[Bug] Saving an Elementor Theme Builder template does not invalidate Ally's cached remediated HTML

Open
#540 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
JavaScript
Stars
95
Forks
43
Avg merge
3d 23h
Merged PRs (30d)
2

Description

## Summary

When an Elementor Theme Builder template (global header, footer, single template, archive template, etc.) is saved, Ally does not invalidate its cached remediated HTML for the pages that render that template.

Anonymous visitors can continue to receive the previously cached HTML, still reflecting the old template, until Ally's cache is cleared manually or each affected page is re-saved individually.

Logged-in users do not see the symptom because Ally's cache-serving path is gated on `! is_user_logged_in()`.

## Affected version

Ally 4.1.1 (`pojo-accessibility.php` line 8, `EA11Y_VERSION` constant line 28).
Also observed on a production site with Elementor Pro Theme Builder and an Elementor Pro Mega Menu.

## Reproduction

Assumes Elementor and Elementor Pro are installed, Ally is connected, and any third-party page cache is disabled or bypassed so the observed behaviour is Ally's own per-page cache (`wp_ea11y_page_scanned.full_html`).

1. Activate Ally and connect it to the Elementor account. Ally's remediation runner only loads once `Connect::is_connected()` returns true (`modules/remediation/module.php:27`).
2. Publish a standard page, for example `/example-page/`.
3. In Elementor → Templates → Theme Builder, create a Header template, add identifiable text such as `MENU-V1` to a navigation widget or text element, publish it, and assign it site-wide.
4. Open `/example-page/` in an incognito window, or via `curl`. Confirm `MENU-V1` is rendered.
5. Confirm Ally has cached HTML for this URL:

```sql
SELECT url, full_html IS NOT NULL AS has_cache
FROM wp_ea11y_page_scanned
WHERE url LIKE '%/example-page%';
```

6. Edit the Header template, change `MENU-V1` to `MENU-V2`, and click Update.
7. Reload `/example-page/` anonymously. The header can still show `MENU-V1`.
8. Reload the same page while logged in as an admin. The header shows `MENU-V2`.
9. As a control, edit `/example-page/` itself and click Update. Ally clears `full_html` for that page row, and the next anonymous load shows `MENU-V2`.

## Expected vs actual

**Expected:** saving a Theme Builder template invalidates Ally's cached HTML for every page on which that template renders.

**Actual:** Ally does not appear to observe Theme Builder template saves. Cached rows in `wp_ea11y_page_scanned` are left intact and can continue to be served until invalidation is triggered by something else, such as:

- a third-party cache plugin firing one of its "purge all" hooks,
- an admin clearing Ally's cache manually,
- `DELETE /wp-json/ea11y/v1/clear-cache`,
- or each affected public page being re-saved individually.

## Root cause

The post-save invalidation hook is registered in `Cache_Cleaner::__construct()` (`modules/remediation/components/cache-cleaner.php:194`):

```php
add_action( 'save_post', [ $this, 'clean_post_cache' ], 10, 3 );
```

The handler returns early for any post whose post type has `public => false` (`modules/remediation/components/cache-cleaner.php:173-187`):

```php
public function clean_post_cache( $post_ID, $post, $update ) {
// Only on publish post for public post type
$post_type_object = get_post_type_object( $post->post_type );
if (
( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) ||
( ! $post_type_object || ! $post_type_object->public ) ||
'publish' !== $post->post_status
) {
return;
}

$post_url = get_permalink( $post_ID );
$url_trimmed = rtrim( $post_url, '/' );
Page_Entry::clear_cache( $url_trimmed );
}
```

Theme Builder templates are stored as `elementor_library` posts. The `elementor_library` CPT is registered in Elementor core, outside this repository, with `public => false`. Please confirm in your codebase. This makes `! $post_type_object->public` evaluate true on every Theme Builder save and causes the method to return before reaching `Page_Entry::clear_cache()`. The `save_post` action still fires for these posts; the handler opts out.

There is also a deeper dependency issue: even if this guard were bypassed, `clean_post_cache()` clears cache for the saved post's own permalink. A Theme Builder template does not map cleanly to one public URL. A global header, footer, archive template, single template, or loop item may affect many public URLs.

No other code path in this plugin appears to observe Theme Builder template saves to fill that gap. A search across all non-vendor PHP files for `elementor_library`, `elementor/document/after_save`, `get_template_type`, `Theme_Builder`, and `Locations_Manager` returned zero matches. The cache plugin integrations in `add_cache_plugins_clean_actions()` listen for third-party purge events (`rocket_after_clean_domain`, `litespeed_purged_all`, etc.), not for Elementor saves.

The anonymous-only symptom is explained by `Remediation_Runner::run_remediations()` (`modules/remediation/components/remediation-runner.php:199-211`):

```php
public function run_remediations( $buffer ): string {
$is_params_empty = empty( $_POST ) && empty( $_GET );
if ( ! is_user_logged_in() && $this->page_html && $this->page->is_valid_hash() && $is_params_empty ) {
return $this->page_html;
}

$dom = $this->generate_remediation_dom( $buffer );

if ( ! is_user_logged_in() && $is_params_empty ) {
$this->page->update_html( $dom );
}
return $dom;
}
```

Cached HTML is only served and only written when the visitor is logged out. Logged-in editors regenerate the DOM from the live output buffer on each load.

## Suggested direction

One possible fix would be to add a second handler that hooks `save_post_elementor_library` and clears Ally's cache site-wide. The site-wide scope matches the existing behaviour when third-party cache plugins signal a global purge in `add_cache_plugins_clean_actions()`.

```php
public function __construct() {
$this->add_cache_plugins_clean_actions();

add_action( 'created_term', [ $this, 'clean_taxonomy_cache' ], 10, 3 );
add_action( 'edited_term', [ $this, 'clean_taxonomy_cache' ], 10, 3 );
add_action( 'save_post', [ $this, 'clean_post_cache' ], 10, 3 );
add_action( 'save_post_elementor_library', [ $this, 'clean_elementor_library_cache' ], 10, 3 );
}

public function clean_elementor_library_cache( $post_ID, $post, $update ) {
if (
( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) ||
'publish' !== $post->post_status
) {
return;
}

self::clear_ally_cache();
}
```

An alternative would be to hook `elementor/document/after_save` and gate on `$document->get_template_type()` to clear only on Theme Builder document types. That would be more selective, but it couples Ally to Elementor's Document API and may not catch non-editor save paths such as WP-CLI or direct REST writes.

A site-wide Ally cache clear seems appropriate because Theme Builder templates do not have a single public URL. Determining exactly which cached URLs are affected by a given header/footer/archive/single template would require querying Elementor's Theme Builder conditions and locations API, which is more complex and version-sensitive.

## Secondary occurrence

`Utils::trigger_save_for_clean_cache()` applies the same public-post-type filter (`modules/remediation/classes/utils.php:143-155`) and appears to back the `POST /wp-json/ea11y/v1/trigger-save` endpoint:

```php
public static function trigger_save_for_clean_cache( $entry_id, $entry_type ): void {
$entry_id = (int) $entry_id;
$post_types = get_post_types([
'public' => true,
], 'names');

if (
! is_numeric( $entry_id ) ||
intval( $entry_id ) <= 0 ||
! in_array( $entry_type, array_merge( [ 'taxonomy' ], $post_types ), true )
) {
return;
}
// ...
}
```

Calls to trigger-save with `object_type=elementor_library` appear to be dropped here for the same reason.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.