Skip to content

Commit 0f0872b

Browse files
authored
Merge pull request #3594 from AlchemyCMS/page-etag-with-scheduled-elements
Include published elements in page ETag for scheduled content
2 parents 74dabc1 + 74d7a11 commit 0f0872b

File tree

3 files changed

+74
-10
lines changed

3 files changed

+74
-10
lines changed

app/controllers/alchemy/pages_controller.rb

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,13 @@ def signup_required?
215215

216216
# Returns the etag used for response headers.
217217
#
218-
# If a user is logged in, we append theirs etag to prevent caching of user related content.
218+
# The etag is composed of:
219+
# - The page's cache key (includes updated_at timestamp)
220+
# - Published element IDs (changes when elements enter/leave the published scope)
221+
# - The current user's cache key (for user-specific content)
222+
#
223+
# This ensures HTTP caches invalidate when scheduled elements become visible
224+
# or hidden, even though the page's updated_at hasn't changed.
219225
#
220226
# IMPORTANT:
221227
#
@@ -224,7 +230,8 @@ def signup_required?
224230
# Otherwise all users will see the same cached page, regardless of user's state.
225231
#
226232
def page_etag
227-
[@page, current_alchemy_user]
233+
elements_cache_key = @page.public_version&.elements&.published&.order(:id)&.pluck(:id)
234+
[@page, elements_cache_key, current_alchemy_user]
228235
end
229236

230237
# We only render the page if either the cache is disabled for this page
@@ -233,7 +240,6 @@ def page_etag
233240
def render_fresh_page?
234241
must_not_cache? || stale?(
235242
etag: page_etag,
236-
last_modified: @page.last_modified_at,
237243
public: !@page.restricted,
238244
template: "pages/show"
239245
)

spec/controllers/alchemy/pages_controller_spec.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,11 @@ module Alchemy
246246
expect(subject).to include(page)
247247
end
248248

249+
it "includes published elements cache key" do
250+
expected = page.public_version.elements.published.order(:id).pluck(:id)
251+
expect(subject[1]).to eq(expected)
252+
end
253+
249254
context "with user logged in" do
250255
before do
251256
authorize_user(mock_model(Alchemy.user_class, cache_key_with_version: "bbb"))
@@ -255,6 +260,41 @@ module Alchemy
255260
expect(subject).to include(an_instance_of(Alchemy.user_class))
256261
end
257262
end
263+
264+
context "when element becomes published" do
265+
let!(:scheduled_element) do
266+
create(:alchemy_element, page_version: page.public_version, public_on: 1.hour.from_now)
267+
end
268+
269+
it "changes the etag when element becomes visible" do
270+
etag_before = subject[1]
271+
travel 2.hours do
272+
etag_after = controller.send(:page_etag)[1]
273+
expect(etag_after).not_to eq(etag_before)
274+
end
275+
end
276+
end
277+
278+
context "when one element replaces another" do
279+
let!(:default_header) do
280+
create(:alchemy_element, page_version: page.public_version, public_on: 1.day.ago, public_until: 1.hour.from_now)
281+
end
282+
283+
let!(:seasonal_header) do
284+
create(:alchemy_element, page_version: page.public_version, public_on: 1.hour.from_now)
285+
end
286+
287+
it "changes the etag even when published element count stays the same" do
288+
etag_before = subject[1]
289+
expect(etag_before.length).to eq(2)
290+
291+
travel 2.hours do
292+
etag_after = controller.send(:page_etag)[1]
293+
expect(etag_after.length).to eq(2)
294+
expect(etag_after).not_to eq(etag_before)
295+
end
296+
end
297+
end
258298
end
259299
end
260300
end

spec/requests/alchemy/page_request_caching_spec.rb

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,19 +97,37 @@
9797
expect(response.headers).to have_key("ETag")
9898
end
9999

100-
context "and public version is present" do
101-
let(:jan_first) { Time.new(2020, 1, 1) }
100+
context "with scheduled element becoming visible" do
101+
let!(:scheduled_element) do
102+
create(:alchemy_element, page_version: page.public_version, public_on: 1.hour.from_now)
103+
end
102104

103-
before do
104-
allow_any_instance_of(Alchemy::Page).to receive(:last_modified_at) { jan_first }
105+
it "changes the etag when element becomes visible" do
106+
get "/#{page.urlname}"
107+
etag_before = response.headers["ETag"]
108+
109+
travel 2.hours do
110+
get "/#{page.urlname}"
111+
etag_after = response.headers["ETag"]
112+
expect(etag_after).not_to eq(etag_before)
113+
end
105114
end
106115

107-
it "sets last-modified header" do
116+
it "returns 200 instead of 304 after element becomes visible" do
108117
get "/#{page.urlname}"
109-
expect(response.headers).to have_key("Last-Modified")
110-
expect(response.headers["Last-Modified"]).to eq(jan_first.httpdate)
118+
etag = response.headers["ETag"]
119+
120+
travel 2.hours do
121+
get "/#{page.urlname}", headers: {"If-None-Match" => etag}
122+
expect(response.status).to eq(200)
123+
end
111124
end
112125
end
126+
127+
it "does not set last-modified header" do
128+
get "/#{page.urlname}"
129+
expect(response.headers).to_not have_key("Last-Modified")
130+
end
113131
end
114132

115133
context "but page should not be cached" do

0 commit comments

Comments
 (0)