diff --git a/docs/HOST_APP_GUIDE.md b/docs/HOST_APP_GUIDE.md
index 61465b60..675acd52 100644
--- a/docs/HOST_APP_GUIDE.md
+++ b/docs/HOST_APP_GUIDE.md
@@ -4,6 +4,14 @@
Coplan is a Rails engine that manages collaborative planning documents. It owns its own `CoPlan::User` model and handles authentication internally via a callback you configure.
+## Rendered-content caching
+
+Rendered plan bodies, comments, previews, and citations use standard Rails
+caching with a 15-minute expiry. Content edits change the cache key immediately.
+Renderer code changes appear as entries expire; no version bump or release
+configuration is required. The host's normal cache store and fragment-caching
+settings apply.
+
## User settings and host integrations
Coplan owns the settings shown on its Settings page. The app theme, light/dark
diff --git a/engine/app/helpers/coplan/markdown_helper.rb b/engine/app/helpers/coplan/markdown_helper.rb
index f9dd4c00..178b18c5 100644
--- a/engine/app/helpers/coplan/markdown_helper.rb
+++ b/engine/app/helpers/coplan/markdown_helper.rb
@@ -42,12 +42,6 @@ module MarkdownHelper
# (ordered-list or blockquoted tasks) stay disabled.
TASK_LINE_PATTERN = /\A\s*[*+-]\s+\[[ xX]\]\s/
- # Fragment caches of rendered markdown are keyed on content SHA plus this
- # version. Bump it whenever the rendering pipeline changes output for the
- # same input (new tags, attribute changes, checkbox wiring, etc.), or
- # stale HTML will be served from cache.
- RENDER_CACHE_VERSION = 32
-
# Matches `[@username](mention:username)` where the bracket text and link
# target encode the same username. Username allows letters, digits, dots,
# dashes, and underscores. The pattern must round-trip exactly so that
diff --git a/engine/app/helpers/coplan/plans_helper.rb b/engine/app/helpers/coplan/plans_helper.rb
index 97a4a35b..efce52e0 100644
--- a/engine/app/helpers/coplan/plans_helper.rb
+++ b/engine/app/helpers/coplan/plans_helper.rb
@@ -159,9 +159,9 @@ def plan_content_preview(plan, limit: 200)
# full Commonmarker + Nokogiri parse per plan without an AI summary.
# The document body itself is fetched only on a cache miss — list pages
# preload just the stub (id + sha), never the MEDIUMTEXT columns.
- cache_key = [ "coplan/plan-preview", MarkdownHelper::RENDER_CACHE_VERSION, plan.id,
+ cache_key = [ "coplan/plan-preview", plan.id,
stub.content_sha256 || plan.current_revision ]
- plain = Rails.cache.fetch(cache_key) do
+ plain = Rails.cache.fetch(cache_key, expires_in: 15.minutes) do
markdown_to_plain_text(PlanVersion.where(id: stub.id).pick(:content_markdown))
end
return nil if plain.blank?
diff --git a/engine/app/helpers/coplan/references_helper.rb b/engine/app/helpers/coplan/references_helper.rb
index bd323845..7e81eb19 100644
--- a/engine/app/helpers/coplan/references_helper.rb
+++ b/engine/app/helpers/coplan/references_helper.rb
@@ -43,10 +43,9 @@ def plan_citation_back_matter(plan, references)
signature = [ plan.current_revision, reference_digest ]
cached = Rails.cache.fetch([
"coplan/plan-citation-back-matter",
- MarkdownHelper::RENDER_CACHE_VERSION,
plan.id,
*signature
- ]) do
+ ], expires_in: 15.minutes) do
result = build_plan_citation_back_matter(plan, references)
[ result[:html].to_s, result[:cited_urls].to_a, result[:count] ]
end
diff --git a/engine/app/views/coplan/comments/_comment.html.erb b/engine/app/views/coplan/comments/_comment.html.erb
index 96a592f8..d2f57500 100644
--- a/engine/app/views/coplan/comments/_comment.html.erb
+++ b/engine/app/views/coplan/comments/_comment.html.erb
@@ -29,8 +29,7 @@
- <%# The record version invalidates edits; the renderer version invalidates cached HTML. %>
- <%= cache(["comment-body-v2", CoPlan::MarkdownHelper::RENDER_CACHE_VERSION, comment], skip_digest: true) do %>
+ <%= cache(["comment-body-v2", comment], expires_in: 15.minutes, skip_digest: true) do %>
<%# footnote_prefix keeps footnote ids unique per comment — the plan
body and every other comment render in the same DOM. Comment
images don't expand: the comments panel and footnote previews
diff --git a/engine/app/views/coplan/plans/_content_body.html.erb b/engine/app/views/coplan/plans/_content_body.html.erb
index d316d49c..8eaf612b 100644
--- a/engine/app/views/coplan/plans/_content_body.html.erb
+++ b/engine/app/views/coplan/plans/_content_body.html.erb
@@ -5,8 +5,7 @@
Cached per content SHA: rendering is Commonmarker + two Nokogiri passes +
sanitize, and this partial is re-rendered on every show AND every
content-mutation broadcast. skip_digest avoids the template-digest cost
- on write paths; the explicit RENDER_CACHE_VERSION key handles renderer
- changes instead. -%>
-<% cache [CoPlan::EmbedDomain.order(:hostname).pluck(:hostname), request&.host, "coplan/plan-content-body-with-title", CoPlan::MarkdownHelper::RENDER_CACHE_VERSION, plan.id, plan.current_plan_version&.content_sha256 || plan.current_revision], skip_digest: true do %>
+ on write paths; expiry picks up renderer changes within 15 minutes. -%>
+<% cache [CoPlan::EmbedDomain.order(:hostname).pluck(:hostname), request&.host, "coplan/plan-content-body-with-title", plan.id, plan.current_plan_version&.content_sha256 || plan.current_revision], expires_in: 15.minutes, skip_digest: true do %>
<%= render_plan_document(plan.current_content) %>
<% end %>
diff --git a/spec/helpers/plans_helper_spec.rb b/spec/helpers/plans_helper_spec.rb
index cea91907..672b00cb 100644
--- a/spec/helpers/plans_helper_spec.rb
+++ b/spec/helpers/plans_helper_spec.rb
@@ -1,6 +1,10 @@
require "rails_helper"
RSpec.describe CoPlan::PlansHelper, type: :helper do
+ include ActiveSupport::Testing::TimeHelpers
+
+ after { travel_back }
+
describe "#render_plan_document" do
before { helper.extend(CoPlan::SlideshowsHelper) }
it "preserves the numbered title fragment alongside the canonical title anchor" do
@@ -16,6 +20,17 @@
describe "#plan_content_preview" do
let(:plan) { create(:plan, :considering) }
+ it "reuses previews for 15 minutes" do
+ allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
+ allow(helper).to receive(:markdown_to_plain_text).and_return("First rendering", "New rendering")
+
+ expect(helper.plan_content_preview(plan)).to eq("First rendering")
+ expect(helper.plan_content_preview(plan)).to eq("First rendering")
+ travel 15.minutes + 1.second
+ expect(helper.plan_content_preview(plan)).to eq("New rendering")
+ expect(helper).to have_received(:markdown_to_plain_text).twice
+ end
+
it "strips markdown formatting and returns a plain-text preview" do
set_version_fixture(plan.current_plan_version,
content_markdown: "# Heading\n\nA **bold** intro with [a link](https://example.com)."
diff --git a/spec/helpers/references_helper_spec.rb b/spec/helpers/references_helper_spec.rb
index 1d6042bc..8dbba114 100644
--- a/spec/helpers/references_helper_spec.rb
+++ b/spec/helpers/references_helper_spec.rb
@@ -1,6 +1,10 @@
require "rails_helper"
RSpec.describe CoPlan::ReferencesHelper, type: :helper do
+ include ActiveSupport::Testing::TimeHelpers
+
+ after { travel_back }
+
describe "#plan_citation_back_matter" do
let(:user) { create(:coplan_user) }
let(:plan) do
@@ -20,6 +24,18 @@
plan
end
+ it "reuses citations for 15 minutes" do
+ allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
+ allow(helper).to receive(:render_markdown).and_call_original
+
+ original = helper.plan_citation_back_matter(plan, plan.references)
+ expect(helper.plan_citation_back_matter(plan, plan.references)).to eq(original)
+ expect(helper).to have_received(:render_markdown).once
+ travel 15.minutes + 1.second
+ expect(helper.plan_citation_back_matter(plan, plan.references)).to eq(original)
+ expect(helper).to have_received(:render_markdown).twice
+ end
+
it "joins Markdown citation context to structured source identity" do
reference = plan.references.find_by!(url: "https://docs.google.com/spreadsheets/d/sample")
reference.update!(title: "Rollout experiment results")
diff --git a/spec/requests/comments_spec.rb b/spec/requests/comments_spec.rb
index fd45d8c9..84664f7a 100644
--- a/spec/requests/comments_spec.rb
+++ b/spec/requests/comments_spec.rb
@@ -1,6 +1,10 @@
require "rails_helper"
RSpec.describe "Comments", type: :request do
+ include ActiveSupport::Testing::TimeHelpers
+
+ after { travel_back }
+
let(:alice) { create(:coplan_user, :admin) }
let(:plan) { create(:plan, :considering, created_by_user: alice) }
let(:thread_record) { create(:comment_thread, plan: plan, plan_version: plan.current_plan_version, created_by_user: alice) }
@@ -61,7 +65,7 @@
ActionController::Base.perform_caching = original_perform_caching
end
- it "refreshes cached comment links when the Markdown renderer version changes" do
+ it "refreshes cached comment links after 15 minutes" do
comment = create(:comment, comment_thread: thread_record, author_id: alice.id,
body_markdown: "[Open chat](codex://threads/123)")
original_caching = CoPlan::ApplicationController.perform_caching
@@ -69,18 +73,16 @@
memory = ActiveSupport::Cache::MemoryStore.new
allow(Rails).to receive(:cache).and_return(memory)
allow_any_instance_of(CoPlan::ApplicationController).to receive(:cache_store).and_return(memory)
- renderer_version = CoPlan::MarkdownHelper::RENDER_CACHE_VERSION
link_scrubber = CoPlan::LinkScrubber
render_comment = -> { CoPlan::ApplicationController.render(partial: "coplan/comments/comment", locals: { comment: comment }) }
- stub_const("CoPlan::MarkdownHelper::RENDER_CACHE_VERSION", renderer_version - 1)
stub_const("CoPlan::LinkScrubber", Rails::HTML::PermitScrubber)
expect(render_comment.call).not_to include('href="codex://threads/123"')
stub_const("CoPlan::LinkScrubber", link_scrubber)
# The old renderer's cached fragment really is still being served.
expect(render_comment.call).not_to include('href="codex://threads/123"')
- stub_const("CoPlan::MarkdownHelper::RENDER_CACHE_VERSION", renderer_version)
+ travel 15.minutes + 1.second
expect(render_comment.call).to include('href="codex://threads/123"')
ensure
CoPlan::ApplicationController.perform_caching = original_caching
@@ -104,7 +106,10 @@ def update_comment(body = "Revised **answer**")
headers: { "Accept" => "text/vnd.turbo-stream.html" }
end
- it "updates the author's comment and broadcasts a requestless replacement" do
+ it "updates the author's cached comment and broadcasts a requestless replacement" do
+ allow_any_instance_of(CoPlan::ApplicationController).to receive(:perform_caching).and_return(true)
+ allow_any_instance_of(CoPlan::ApplicationController).to receive(:cache_store).and_return(ActiveSupport::Cache::MemoryStore.new)
+ expect(CoPlan::ApplicationController.render(partial: "coplan/comments/comment", locals: { comment: comment })).to include("Original")
allow(CoPlan::Broadcaster).to receive(:replace_to)
expect(CoPlan::Broadcaster).to receive(:replace_to).with(plan,
target: ActionView::RecordIdentifier.dom_id(comment), partial: "coplan/comments/comment", locals: { comment: comment })
diff --git a/spec/requests/plan_content_editing_spec.rb b/spec/requests/plan_content_editing_spec.rb
index fe68bb5f..7ef6e7b5 100644
--- a/spec/requests/plan_content_editing_spec.rb
+++ b/spec/requests/plan_content_editing_spec.rb
@@ -1,6 +1,10 @@
require "rails_helper"
RSpec.describe "Plan content editing (web UI)", type: :request do
+ include ActiveSupport::Testing::TimeHelpers
+
+ after { travel_back }
+
let(:author) { create(:coplan_user) }
let(:other_user) { create(:coplan_user) }
let(:plan) { create(:plan, :considering, created_by_user: author) }
@@ -28,30 +32,23 @@
end
describe "GET content_body" do
- it "invalidates pre-alias rendered fragments without changing the plan" do
+ it "refreshes stale rendered fragments after 15 minutes without changing the plan" do
set_version_fixture(plan.current_plan_version, content_markdown: "# 1 Overview\n\nOpening text.\n")
cache = ActiveSupport::Cache::MemoryStore.new
allow_any_instance_of(CoPlan::PlansController).to receive(:perform_caching).and_return(true)
allow_any_instance_of(CoPlan::PlansController).to receive(:cache_store).and_return(cache)
- written_keys = []
- allow(cache).to receive(:write).and_wrap_original do |write, key, *args, **options|
- written_keys << key
- write.call(key, *args, **options)
+ # Simulate an older renderer's output, retaining the real cache expiry.
+ strip_alias = true
+ allow(cache).to receive(:write).and_wrap_original do |write, key, value, options|
+ value = value.gsub(/]*data-heading-alias[^>]*><\/span>/, "") if strip_alias
+ write.call(key, value, options)
end
- renderer_version = CoPlan::MarkdownHelper::RENDER_CACHE_VERSION
-
- # Simulate a persistent fragment written by the renderer before title aliases.
- stub_const("CoPlan::MarkdownHelper::RENDER_CACHE_VERSION", 24)
get content_body_plan_path(plan)
- expect(response).to have_http_status(:ok)
- fragment_key = written_keys.find { |key| key.to_s.include?("coplan/plan-content-body-with-title") }
- expect(fragment_key).to be_present
- stale_html = response.body.gsub(/]*data-heading-alias[^>]*><\/span>/, "")
- cache.write(fragment_key, stale_html)
+ strip_alias = false
get content_body_plan_path(plan)
expect(Nokogiri::HTML.fragment(response.body).at_css("#section-1")).to be_nil
- stub_const("CoPlan::MarkdownHelper::RENDER_CACHE_VERSION", renderer_version)
+ travel 15.minutes + 1.second
get content_body_plan_path(plan)
expect(response).to have_http_status(:ok)
expect(Nokogiri::HTML.fragment(response.body).at_css("h1#plan-title #section-1")).to be_present
@@ -92,7 +89,12 @@
end
describe "PATCH update_content" do
- it "creates a new human-authored version through ReplaceContent" do
+ it "creates a new human-authored version and immediately refreshes cached content" do
+ allow_any_instance_of(CoPlan::PlansController).to receive(:perform_caching).and_return(true)
+ allow_any_instance_of(CoPlan::PlansController).to receive(:cache_store).and_return(ActiveSupport::Cache::MemoryStore.new)
+ get content_body_plan_path(plan)
+ expect(response.body).to include("Original body.")
+
expect {
patch update_content_plan_path(plan), params: {
content: "# Plan\n\nEdited body.\n",
@@ -108,6 +110,9 @@
expect(version.actor_type).to eq("human")
expect(version.actor_id).to eq(author.id)
expect(version.change_summary).to eq("Tightened wording")
+ get content_body_plan_path(plan)
+ expect(response.body).to include("Edited body.")
+ expect(response.body).not_to include("Original body.")
end
it "defaults the change summary" do