Skip to content

Add element-level opt-out for page caching - #3980

Merged
tvdeyen merged 5 commits into
AlchemyCMS:mainfrom
robinboening:feature_element_level_page_cache_optout
Aug 11, 2026
Merged

Add element-level opt-out for page caching#3980
tvdeyen merged 5 commits into
AlchemyCMS:mainfrom
robinboening:feature_element_level_page_cache_optout

Conversation

@robinboening

@robinboening robinboening commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Closes #3973.

This adds a page_cache option to element definitions, allowing individual elements to opt a page out of HTTP page caching:

- name: restricted_downloads
  page_cache: false

When a page contains an element definition with page_cache: false, Alchemy now always renders the page instead of relying on the page-cache. This lets dynamic or permission-sensitive elements execute and apply their own fragment cache keys.

Uncacheable page responses now use Cache-Control: no-store instead of no-cache. no-cache still lets a cache store the response and reuse it after a 304, which serves stale content (this is why stale flash messages could appear) and skips re-rendering elements. no-store guarantees the page is rendered fresh. This applies to every reason a page is uncacheable — global config, page-layout cache: false, a flash message, and the new element-level opt-out.

The page_cache option defaults to true and is kept as definition-only metadata, so element definitions behave as before unless one explicitly opts out. Note that the no-cacheno-store switch does change the cache-control header for pages that were already uncacheable.

Checklist

  • I have followed Pull Request guidelines
  • I have added a detailed description into each commit message
  • I have added tests to cover this change

@robinboening
robinboening requested a review from a team as a code owner June 17, 2026 16:51
@codecov

codecov Bot commented Jun 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.28%. Comparing base (990cb5c) to head (807606c).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #3980   +/-   ##
=======================================
  Coverage   98.28%   98.28%           
=======================================
  Files         352      352           
  Lines        9276     9281    +5     
=======================================
+ Hits         9117     9122    +5     
  Misses        159      159           
Flag Coverage Δ
mariadb 96.83% <100.00%> (+<0.01%) ⬆️
postgresql 95.70% <100.00%> (+<0.01%) ⬆️
sqlite 95.70% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@robinboening
robinboening force-pushed the feature_element_level_page_cache_optout branch 2 times, most recently from 19a8a0b to e5c19c5 Compare June 17, 2026 21:03
tvdeyen
tvdeyen previously approved these changes Jul 29, 2026

@tvdeyen tvdeyen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that feature. Would you mind to fix the remaining specs? We plan to make significant changes to the repo and this will break this PR. Also we plan to release 8.4 soon and we would include in that release

@tvdeyen
tvdeyen force-pushed the feature_element_level_page_cache_optout branch from e5c19c5 to 8572837 Compare July 29, 2026 08:03
@robinboening

Copy link
Copy Markdown
Contributor Author

I like that feature. Would you mind to fix the remaining specs? We plan to make significant changes to the repo and this will break this PR. Also we plan to release 8.4 soon and we would include in that release

Fixed. The expectation was wrong. The dynamically created uncached_layout does not specify cache: 60, so Page#expiration_time correctly falls back to the configured default of 600 seconds.

@tvdeyen tvdeyen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice feature — the no-store reasoning is spot on. 👍 One thing to flag: page_cache_disabled_by_elements? runs on every show/index, and because it calls @page.find_elements, it now loads and instantiates the full published element set on every request — including conditional GETs that return 304. Previously a 304 only ran the lightweight EtagGenerator pluck and never materialized elements, so this adds real work to the hot cache path. It's also evaluated up to 3× per request (twice in set_expiration_headers, once via render_fresh_page?) with no memoization.

Two small things make it nearly free again:

  1. Short-circuit statically — in the common case no definition opts out, so we can skip the DB entirely; and when one does, an exists? is far cheaper than loading full records (it also naturally covers nested/fixed elements, which find_elements' default visible.not_nested.unfixed scope currently misses).
  2. Memoize per request (guarding with defined? since the result can be false).
def page_cache_disabled_by_elements?
  return @page_cache_disabled_by_elements if defined?(@page_cache_disabled_by_elements)

  opt_out_names = Alchemy::Element.definitions.filter_map { |d| d.name if d.page_cache == false }
  @page_cache_disabled_by_elements = !!(
    opt_out_names.any? &&
      @page&.public_version&.elements&.published&.exists?(name: opt_out_names)
  )
end

This keeps behavior identical for the specs here, stays zero-cost for apps that don't use the opt-out, and closes the nested/fixed-element gap for free. 🙂

@tvdeyen
tvdeyen dismissed their stale review August 5, 2026 12:46

performance regression

@tvdeyen

tvdeyen commented Aug 6, 2026

Copy link
Copy Markdown
Member

Followed up on my suggestion above — applied it locally and it checks out green:

  • spec/requests/alchemy/page_request_caching_spec.rb + spec/models/alchemy/element_definition_spec.rb → 65 examples, 0 failures (the opt-out specs pass unchanged)
  • spec/controllers/alchemy/pages_controller_spec.rb → 23 examples, 0 failures
  • standardrb clean

Behavior is identical; it just skips the element load on the 304 hot path. Happy to push the commit to this branch if that's easier for you. 🙂

Allow element definitions to declare page_cache: false so pages that contain or can render those elements skip HTTP page-shell caching.

Use no-store for this opt-out because no-cache still permits stored responses and conditional revalidation, which can return 304 before element-level cache variants are rendered.

Keep the option as definition-only metadata and cover the new behavior with model and request specs.
@tvdeyen
tvdeyen force-pushed the feature_element_level_page_cache_optout branch from 555daa2 to a584eeb Compare August 11, 2026 09:09
@tvdeyen

tvdeyen commented Aug 11, 2026

Copy link
Copy Markdown
Member

@robinboening I would like to take this over if you don't mind?

@tvdeyen tvdeyen added this to the 8.4 milestone Aug 11, 2026
The element-level page cache opt-out ran on every show/index request and,
via find_elements, loaded and instantiated the full published element set
even for conditional GETs that return 304 — the exact hot path page caching
exists to keep cheap. It was also evaluated up to three times per request
without memoization.

Short-circuit on the statically known opt-out element definitions so apps
that don't use the feature never touch the database, and fall back to a
single exists? query instead of materializing every element when they do.
Memoize the result per request. Querying published elements directly also
covers nested and fixed elements, which the find_elements default scope
silently skipped.
Disabling page caching — whether globally, per page definition, per element
definition, or because a flash message is present — must guarantee that no
stored copy is ever served. no-cache did not: it still permits caches to
store the response and satisfy later requests with a conditional 304, which
is why stale flash messages kept surfacing and why element-level cache
variants could be skipped.

Serve no-store for the whole must_not_cache? path instead of no-cache. Since
the element opt-out is already one of its conditions, the dedicated branch
collapses into it. This restores the intended "caching disabled means never
stale" behavior rather than introducing a new cache mode.
The previous wording ("element-level cache variants" / "per-request
caching") was inaccurate. Elements are fragment-cached in the views via
<% cache element %>, not per request. Reword the comment to describe the
concrete reason for no-store: a stored response reused after a 304 skips
re-rendering elements that opted out of page caching.
@robinboening

Copy link
Copy Markdown
Contributor Author

@tvdeyen thanks for taking this over, I was not at my computer for a few days, and am still very busy with some other things so I probably won't be able to get to it this week or the next. 👍

@tvdeyen tvdeyen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a fix for the performance regression we would have hit and changed that we always return no-store for uncachable pages. Thanks for the contribution, this is a very welcome change.

@tvdeyen tvdeyen added the enhancement New feature or enhancement label Aug 11, 2026
@tvdeyen
tvdeyen merged commit 70b1155 into AlchemyCMS:main Aug 11, 2026
29 checks passed
@robinboening
robinboening deleted the feature_element_level_page_cache_optout branch August 11, 2026 11:28
@robinboening

Copy link
Copy Markdown
Contributor Author

Thanks! I also have some other features in mind that I believe could be nice additions. I'll open the issues when I get some more time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add element-level opt-out for page caching

2 participants