Skip to content

Commit c3f0f93

Browse files
etewiahclaude
andcommitted
Add edit mode-aware fragment caching helpers
Extend CacheHelper with edit mode and website locking compatibility: - Add edit_mode? method that checks params and instance variables - Add compiling_for_lock? for future website locking feature - Add cacheable? convenience method that combines both checks - Add cache_unless_editing helper for conditional fragment caching - Add page_part_cache_key for page part/component caching Update PagesController to skip HTTP caching in edit mode and track @has_rails_parts for future optimization. Update default theme page view to use edit_mode? helper and add documentation comments. Include comprehensive RSpec tests for all new helper methods. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ce841de commit c3f0f93

File tree

4 files changed

+330
-7
lines changed

4 files changed

+330
-7
lines changed

app/controllers/pwb/pages_controller.rb

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ module Pwb
44
class PagesController < ApplicationController
55
include SeoHelper
66
include HttpCacheable
7+
include CacheHelper
78

89
before_action :header_image_url
910
before_action :extract_lcp_image, only: [:show_page]
@@ -17,6 +18,7 @@ def show_page
1718
end
1819
@content_to_show = []
1920
@page_contents_for_edit = []
21+
@has_rails_parts = false
2022

2123
# @page.ordered_visible_contents.each do |page_content|
2224
# above does not get ordered correctly
@@ -25,6 +27,7 @@ def show_page
2527
if page_content.is_rails_part
2628
# Rails parts are rendered as partials in the view, skip content extraction
2729
@content_to_show.push nil
30+
@has_rails_parts = true
2831
else
2932
@content_to_show.push page_content.content&.raw
3033
end
@@ -36,11 +39,14 @@ def show_page
3639
set_page_seo(@page)
3740

3841
# HTTP caching for pages - cache for 10 minutes, stale for 1 hour
39-
set_cache_control_headers(
40-
max_age: 10.minutes,
41-
public: true,
42-
stale_while_revalidate: 1.hour
43-
)
42+
# Skip caching in edit mode to ensure fresh content for editors
43+
unless edit_mode?
44+
set_cache_control_headers(
45+
max_age: 10.minutes,
46+
public: true,
47+
stale_while_revalidate: 1.hour
48+
)
49+
end
4450
end
4551

4652
render "/pwb/pages/show"

app/helpers/cache_helper.rb

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,24 @@
66
# All cache keys are scoped to the current website (tenant) to prevent
77
# cross-tenant data leakage.
88
#
9+
# EDIT MODE & WEBSITE LOCKING:
10+
# When edit_mode is active or website is being compiled for locking,
11+
# caching should be skipped to ensure fresh content is rendered.
12+
# Use `cacheable?` to check before caching, or use `cache_unless_editing`
13+
# helper which handles this automatically.
14+
#
915
# Usage in views:
10-
# <% cache property_cache_key(@property) do %>
16+
# <%# Automatic edit mode handling: %>
17+
# <% cache_unless_editing page_cache_key(@page) do %>
18+
# <%= render @page %>
19+
# <% end %>
20+
#
21+
# <%# Or manual check: %>
22+
# <% if cacheable? %>
23+
# <% cache property_cache_key(@property) do %>
24+
# <%= render @property %>
25+
# <% end %>
26+
# <% else %>
1127
# <%= render @property %>
1228
# <% end %>
1329
#
@@ -168,6 +184,92 @@ def external_listing_cache_key(listing, section = "main")
168184
)
169185
end
170186

187+
# Cache key for a page part/component
188+
# Includes page part version and block contents hash for proper invalidation
189+
# @param page_part [Pwb::PagePart] the page part object
190+
# @param page_content [Pwb::PageContent] optional page content for context-specific caching
191+
def page_part_cache_key(page_part, page_content = nil)
192+
return nil unless page_part
193+
194+
parts = [
195+
"page_part",
196+
page_part.page_part_key,
197+
page_part.updated_at.to_i
198+
]
199+
200+
# Include page content version if provided (for page-specific overrides)
201+
if page_content
202+
parts << "pc#{page_content.id}"
203+
parts << page_content.updated_at.to_i
204+
end
205+
206+
# Include block_contents hash for JSON data changes
207+
if page_part.respond_to?(:block_contents) && page_part.block_contents.present?
208+
parts << Digest::MD5.hexdigest(page_part.block_contents.to_json)[0..8]
209+
end
210+
211+
cache_key_for(*parts)
212+
end
213+
214+
# ---------------------------------------------------------------------------
215+
# Edit Mode & Website Locking Compatibility
216+
# ---------------------------------------------------------------------------
217+
218+
# Check if the current request is in edit mode
219+
# Edit mode should never use cached content as editors need fresh data
220+
def edit_mode?
221+
# Return memoized value if already computed (not nil)
222+
return @_edit_mode unless @_edit_mode.nil?
223+
224+
@_edit_mode = if defined?(params) && params[:edit_mode] == "true"
225+
true
226+
elsif defined?(@edit_mode) && @edit_mode
227+
true
228+
else
229+
false
230+
end
231+
end
232+
233+
# Check if the website is being compiled for locking
234+
# During compilation, we want fresh content, not cached
235+
def compiling_for_lock?
236+
# Return memoized value if already computed (not nil)
237+
return @_compiling_for_lock unless @_compiling_for_lock.nil?
238+
239+
@_compiling_for_lock = if defined?(@compiling_for_lock) && @compiling_for_lock
240+
true
241+
else
242+
false
243+
end
244+
end
245+
246+
# Check if fragment caching should be used
247+
# Returns false when in edit mode or during lock compilation
248+
# This ensures editors always see fresh content and locked pages
249+
# are compiled from the true source of truth
250+
def cacheable?
251+
!edit_mode? && !compiling_for_lock?
252+
end
253+
254+
# Helper to conditionally cache content based on edit mode
255+
# Skips caching when editing, uses cache otherwise
256+
#
257+
# Usage:
258+
# <% cache_unless_editing page_cache_key(@page) do %>
259+
# <%= expensive_render %>
260+
# <% end %>
261+
#
262+
# @param key [String, Array] the cache key (from cache_key_for or similar)
263+
# @param options [Hash] options passed to Rails cache helper
264+
# @yield the content to cache/render
265+
def cache_unless_editing(key, options = {}, &block)
266+
if cacheable? && key.present?
267+
cache(key, options, &block)
268+
else
269+
capture(&block)
270+
end
271+
end
272+
171273
private
172274

173275
def current_website_id

app/themes/default/views/pwb/pages/show.html.erb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
<% page_title @page.page_title %>
2+
<%#
3+
Fragment Caching Strategy:
4+
- Pages WITHOUT Rails parts: Cache the entire page content
5+
- Pages WITH Rails parts: Cache individual Liquid sections only
6+
- Edit mode: Skip all caching (editors need fresh content)
7+
- Compiling for lock: Skip caching (need fresh source for compilation)
8+
%>
29
<!-- MAIN CONTENT -->
310
<div class="bg-gray-100 py-4">
411
<div class="container mx-auto px-4">
@@ -32,10 +39,12 @@
3239
</div>
3340
<div class="container mx-auto px-4 py-8">
3441
<% @page_contents_for_edit.each_with_index do |page_content, index| %>
35-
<div class="w-full mb-6" <%= "data-pwb-page-part=#{page_content.page_part_key}".html_safe if params[:edit_mode] == 'true' %>>
42+
<div class="w-full mb-6" <%= "data-pwb-page-part=#{page_content.page_part_key}".html_safe if edit_mode? %>>
3643
<% if page_content.is_rails_part %>
44+
<%# Rails parts are dynamic - never cache them %>
3745
<%= render partial: "pwb/components/#{page_content.page_part_key}", locals: {} rescue nil %>
3846
<% else %>
47+
<%# Liquid-rendered content - already stored in DB, output directly %>
3948
<%== @content_to_show[index] %>
4049
<% end %>
4150
</div>

spec/helpers/cache_helper_spec.rb

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe CacheHelper, type: :helper do
6+
let(:website) { create(:website) }
7+
8+
# Helper to reset memoized values
9+
def reset_memoization!
10+
helper.instance_variable_set(:@_edit_mode, nil)
11+
helper.instance_variable_set(:@_compiling_for_lock, nil)
12+
helper.instance_variable_set(:@compiling_for_lock, nil)
13+
helper.instance_variable_set(:@edit_mode, nil)
14+
end
15+
16+
before do
17+
reset_memoization!
18+
helper.instance_variable_set(:@current_website, website)
19+
allow(Pwb::Current).to receive(:website).and_return(website)
20+
allow(helper).to receive(:params).and_return({})
21+
end
22+
23+
describe "#cache_key_for" do
24+
it "generates a tenant-scoped cache key" do
25+
key = helper.cache_key_for("test", "parts")
26+
expect(key).to include("w#{website.id}")
27+
expect(key).to include("l#{I18n.locale}")
28+
expect(key).to include("test")
29+
expect(key).to include("parts")
30+
end
31+
32+
it "includes locale in cache key" do
33+
I18n.with_locale(:es) do
34+
key = helper.cache_key_for("test")
35+
expect(key).to include("les")
36+
end
37+
end
38+
end
39+
40+
describe "#edit_mode?" do
41+
context "when params[:edit_mode] is 'true'" do
42+
before do
43+
reset_memoization!
44+
allow(helper).to receive(:params).and_return({ edit_mode: "true" })
45+
end
46+
47+
it "returns true" do
48+
expect(helper.edit_mode?).to be true
49+
end
50+
end
51+
52+
context "when params[:edit_mode] is not present" do
53+
before do
54+
reset_memoization!
55+
allow(helper).to receive(:params).and_return({})
56+
end
57+
58+
it "returns false" do
59+
expect(helper.edit_mode?).to be false
60+
end
61+
end
62+
63+
context "when @edit_mode instance variable is set" do
64+
before do
65+
reset_memoization!
66+
allow(helper).to receive(:params).and_return({})
67+
helper.instance_variable_set(:@edit_mode, true)
68+
end
69+
70+
it "returns true" do
71+
expect(helper.edit_mode?).to be true
72+
end
73+
end
74+
end
75+
76+
describe "#compiling_for_lock?" do
77+
context "when @compiling_for_lock is not set" do
78+
before do
79+
reset_memoization!
80+
end
81+
82+
it "returns false" do
83+
expect(helper.compiling_for_lock?).to be false
84+
end
85+
end
86+
87+
context "when @compiling_for_lock is true" do
88+
before do
89+
reset_memoization!
90+
helper.instance_variable_set(:@compiling_for_lock, true)
91+
end
92+
93+
it "returns true" do
94+
expect(helper.compiling_for_lock?).to be true
95+
end
96+
end
97+
end
98+
99+
describe "#cacheable?" do
100+
context "when not in edit mode and not compiling" do
101+
before do
102+
reset_memoization!
103+
allow(helper).to receive(:params).and_return({})
104+
end
105+
106+
it "returns true" do
107+
expect(helper.cacheable?).to be true
108+
end
109+
end
110+
111+
context "when in edit mode" do
112+
before do
113+
reset_memoization!
114+
allow(helper).to receive(:params).and_return({ edit_mode: "true" })
115+
end
116+
117+
it "returns false" do
118+
expect(helper.cacheable?).to be false
119+
end
120+
end
121+
122+
context "when compiling for lock" do
123+
before do
124+
reset_memoization!
125+
helper.instance_variable_set(:@compiling_for_lock, true)
126+
allow(helper).to receive(:params).and_return({})
127+
end
128+
129+
it "returns false" do
130+
expect(helper.cacheable?).to be false
131+
end
132+
end
133+
end
134+
135+
describe "#page_cache_key" do
136+
it "returns nil for nil page" do
137+
expect(helper.page_cache_key(nil)).to be_nil
138+
end
139+
140+
it "generates a cache key for pages" do
141+
page = website.pages.create!(slug: "test-page", visible: true)
142+
key = helper.page_cache_key(page)
143+
expect(key).to include("page")
144+
expect(key).to include(page.slug)
145+
end
146+
end
147+
148+
describe "#page_part_cache_key" do
149+
it "returns nil for nil page part" do
150+
expect(helper.page_part_cache_key(nil)).to be_nil
151+
end
152+
153+
it "generates a cache key for page parts" do
154+
page_part = website.page_parts.create!(page_part_key: "test/part")
155+
key = helper.page_part_cache_key(page_part)
156+
expect(key).to include("page_part")
157+
expect(key).to include("test/part")
158+
end
159+
end
160+
161+
describe "#property_cache_key" do
162+
it "returns nil for nil property" do
163+
expect(helper.property_cache_key(nil)).to be_nil
164+
end
165+
end
166+
167+
describe "#cache_unless_editing" do
168+
context "when cacheable" do
169+
before do
170+
reset_memoization!
171+
allow(helper).to receive(:params).and_return({})
172+
end
173+
174+
it "calls cache with the key" do
175+
expect(helper).to receive(:cache).with("test_key", {})
176+
helper.cache_unless_editing("test_key") { "content" }
177+
end
178+
end
179+
180+
context "when not cacheable (edit mode)" do
181+
before do
182+
reset_memoization!
183+
allow(helper).to receive(:params).and_return({ edit_mode: "true" })
184+
end
185+
186+
it "does not call cache" do
187+
expect(helper).not_to receive(:cache)
188+
expect(helper).to receive(:capture).and_return("content")
189+
helper.cache_unless_editing("test_key") { "content" }
190+
end
191+
end
192+
193+
context "when key is nil" do
194+
before do
195+
reset_memoization!
196+
allow(helper).to receive(:params).and_return({})
197+
end
198+
199+
it "does not call cache" do
200+
expect(helper).not_to receive(:cache)
201+
expect(helper).to receive(:capture).and_return("content")
202+
helper.cache_unless_editing(nil) { "content" }
203+
end
204+
end
205+
end
206+
end

0 commit comments

Comments
 (0)