diff --git a/app/controllers/alchemy/pages_controller.rb b/app/controllers/alchemy/pages_controller.rb index a01fb8abd7..28bbd1556b 100644 --- a/app/controllers/alchemy/pages_controller.rb +++ b/app/controllers/alchemy/pages_controller.rb @@ -189,7 +189,10 @@ def render_page end def set_expiration_headers - if must_not_cache? + if page_cache_disabled_by_elements? + # no-cache still allows storage and conditional 304 responses, which would skip element-level cache variants. + no_store + elsif must_not_cache? expires_now else expires_in @page.expiration_time, {public: !@page.restricted}.merge(caching_options) @@ -247,7 +250,11 @@ def render_fresh_page? # don't cache pages if we have flash message to display or the page has caching disabled def must_not_cache? - !caching_enabled? || !@page.cache_page? || flash.present? + !caching_enabled? || !@page.cache_page? || flash.present? || page_cache_disabled_by_elements? + end + + def page_cache_disabled_by_elements? + @page&.find_elements&.any? { |element| element.definition.page_cache == false } || false end def caching_enabled? diff --git a/app/models/alchemy/element.rb b/app/models/alchemy/element.rb index 6829b50131..c5c9676ccc 100644 --- a/app/models/alchemy/element.rb +++ b/app/models/alchemy/element.rb @@ -49,6 +49,7 @@ class Element < BaseRecord "ingredients", "message", "nestable_elements", + "page_cache", "searchable", "taggable", "warning" diff --git a/app/models/alchemy/element_definition.rb b/app/models/alchemy/element_definition.rb index e9634a15e6..bbd1fed2a7 100644 --- a/app/models/alchemy/element_definition.rb +++ b/app/models/alchemy/element_definition.rb @@ -13,6 +13,7 @@ class ElementDefinition attribute :taggable, :boolean, default: false attribute :compact, :boolean, default: false attribute :fixed, :boolean, default: false + attribute :page_cache, :boolean, default: true attribute :ingredients, default: [] attribute :nestable_elements, default: [] attribute :autogenerate, default: [] diff --git a/spec/models/alchemy/element_definition_spec.rb b/spec/models/alchemy/element_definition_spec.rb index 0cd47b3084..251fece0e4 100644 --- a/spec/models/alchemy/element_definition_spec.rb +++ b/spec/models/alchemy/element_definition_spec.rb @@ -25,6 +25,7 @@ module Alchemy it { is_expected.to have_key(:taggable) } it { is_expected.to have_key(:compact) } it { is_expected.to have_key(:fixed) } + it { is_expected.to have_key(:page_cache) } it { is_expected.to have_key(:ingredients) } it { is_expected.to have_key(:nestable_elements) } it { is_expected.to have_key(:autogenerate) } @@ -35,6 +36,16 @@ module Alchemy it { is_expected.to have_key(:searchable) } end + describe "#page_cache" do + it "defaults to true" do + expect(described_class.new.page_cache).to be(true) + end + + it "can be disabled" do + expect(described_class.new(page_cache: false).page_cache).to be(false) + end + end + describe "validations" do it { is_expected.to validate_presence_of(:name) } it { is_expected.to allow_value("article").for(:name) } diff --git a/spec/requests/alchemy/page_request_caching_spec.rb b/spec/requests/alchemy/page_request_caching_spec.rb index 73b370ffd8..21c29f71e6 100644 --- a/spec/requests/alchemy/page_request_caching_spec.rb +++ b/spec/requests/alchemy/page_request_caching_spec.rb @@ -124,6 +124,46 @@ end end + context "with an element-level page cache opt-out" do + around do |example| + Alchemy::ElementDefinition.add({"name" => "uncached_element", "page_cache" => false}) + Alchemy::PageDefinition.add({"name" => "uncached_layout", "elements" => ["uncached_element"]}) + + example.run + ensure + Alchemy::ElementDefinition.reset! + Alchemy::PageDefinition.reset! + end + + it "sets no-store when a published element disables page caching" do + create(:alchemy_element, name: "uncached_element", page_version: page.public_version) + + get "/#{page.urlname}" + + expect(response.headers).to have_key("Cache-Control") + expect(response.headers["Cache-Control"]).to eq("no-store") + end + + it "keeps page caching when the page layout allows but does not contain an element that disables page caching" do + uncached_page = create(:alchemy_page, :public, page_layout: "uncached_layout") + + get "/#{uncached_page.urlname}" + + expect(response.headers).to have_key("Cache-Control") + expect(response.headers["Cache-Control"]).to eq("max-age=60, public, must-revalidate") + end + + it "renders without conditional cache revalidation when a published element disables page caching" do + create(:alchemy_element, name: "uncached_element", page_version: page.public_version) + + expect_any_instance_of(Alchemy::PagesController).not_to receive(:stale?) + + get "/#{page.urlname}", headers: {"If-None-Match" => "\"cached-page\""} + + expect(response.status).to eq(200) + end + end + it "does not set last-modified header" do get "/#{page.urlname}" expect(response.headers).to_not have_key("Last-Modified")