From 2eee4b8e6674c3c789c012f2c26a9275baa83171 Mon Sep 17 00:00:00 2001 From: Miguel Date: Tue, 13 Jan 2026 13:58:23 +0100 Subject: [PATCH 1/7] Fix: Use Rails::Engine for Propshaft compatibility ReActionView's dev-tools assets were not found when using Propshaft (Rails 8's default asset pipeline) because it used Sprockets-specific asset path registration. Changed from Rails::Railtie to Rails::Engine, which automatically registers app/assets directories with both Sprockets and Propshaft. --- lib/reactionview.rb | 2 +- lib/reactionview/{railtie.rb => engine.rb} | 17 +++++------------ 2 files changed, 6 insertions(+), 13 deletions(-) rename lib/reactionview/{railtie.rb => engine.rb} (59%) diff --git a/lib/reactionview.rb b/lib/reactionview.rb index aeee3fe..065df47 100644 --- a/lib/reactionview.rb +++ b/lib/reactionview.rb @@ -21,7 +21,7 @@ require_relative "reactionview/template/handlers/herb" require_relative "reactionview/template/handlers/herb/herb" -require_relative "reactionview/railtie" if defined?(Rails::Railtie) +require_relative "reactionview/engine" if defined?(Rails::Railtie) module ReActionView end diff --git a/lib/reactionview/railtie.rb b/lib/reactionview/engine.rb similarity index 59% rename from lib/reactionview/railtie.rb rename to lib/reactionview/engine.rb index 6265b73..06ed212 100644 --- a/lib/reactionview/railtie.rb +++ b/lib/reactionview/engine.rb @@ -1,25 +1,18 @@ # frozen_string_literal: true module ReActionView - class Railtie < Rails::Railtie - # If you don't want to precompile ReActionView's assets (eg. because you're using propshaft), - # you can do this in an initializer: - # - # config.after_initialize do - # config.assets.precompile -= ReActionView::Railtie::PRECOMPILE_ASSETS - # end - # + class Engine < Rails::Engine PRECOMPILE_ASSETS = %w[ reactionview-dev-tools.esm.js reactionview-dev-tools.umd.js ].freeze initializer "reactionview.assets" do |app| + # Sprockets precompilation config (for backward compatibility) if ReActionView.config.development? && app.config.respond_to?(:assets) - gem_root = Gem::Specification.find_by_name("reactionview").gem_dir - - app.config.assets.paths << File.join(gem_root, "app", "assets", "javascripts") - app.config.assets.precompile += PRECOMPILE_ASSETS + if app.config.assets.respond_to?(:precompile) + app.config.assets.precompile += PRECOMPILE_ASSETS + end end end From a9e4adf463db182882ff79b8787ed6029395bf1c Mon Sep 17 00:00:00 2001 From: Miguel Date: Tue, 13 Jan 2026 23:10:42 +0100 Subject: [PATCH 2/7] Fix lint errors --- lib/reactionview/engine.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/reactionview/engine.rb b/lib/reactionview/engine.rb index 06ed212..a95e3fb 100644 --- a/lib/reactionview/engine.rb +++ b/lib/reactionview/engine.rb @@ -9,10 +9,8 @@ class Engine < Rails::Engine initializer "reactionview.assets" do |app| # Sprockets precompilation config (for backward compatibility) - if ReActionView.config.development? && app.config.respond_to?(:assets) - if app.config.assets.respond_to?(:precompile) - app.config.assets.precompile += PRECOMPILE_ASSETS - end + if ReActionView.config.development? && app.config.respond_to?(:assets) && app.config.assets.respond_to?(:precompile) + app.config.assets.precompile += PRECOMPILE_ASSETS end end From fc9c970a873612a8e785b54ff8cd4162483421e4 Mon Sep 17 00:00:00 2001 From: Miguel Date: Wed, 1 Apr 2026 18:53:39 +0200 Subject: [PATCH 3/7] Update lib/reactionview.rb Co-authored-by: Chris Oliver --- lib/reactionview.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/reactionview.rb b/lib/reactionview.rb index 065df47..4b5e4dd 100644 --- a/lib/reactionview.rb +++ b/lib/reactionview.rb @@ -21,7 +21,7 @@ require_relative "reactionview/template/handlers/herb" require_relative "reactionview/template/handlers/herb/herb" -require_relative "reactionview/engine" if defined?(Rails::Railtie) +require_relative "reactionview/engine" if defined?(Rails::Engine) module ReActionView end From 47f4345939af845d1c474e648538435fe3a832cc Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 8 Aug 2026 04:43:32 +0200 Subject: [PATCH 4/7] Revert "Fix lint errors" This reverts commit a9e4adf463db182882ff79b8787ed6029395bf1c. --- lib/reactionview/engine.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/reactionview/engine.rb b/lib/reactionview/engine.rb index a95e3fb..06ed212 100644 --- a/lib/reactionview/engine.rb +++ b/lib/reactionview/engine.rb @@ -9,8 +9,10 @@ class Engine < Rails::Engine initializer "reactionview.assets" do |app| # Sprockets precompilation config (for backward compatibility) - if ReActionView.config.development? && app.config.respond_to?(:assets) && app.config.assets.respond_to?(:precompile) - app.config.assets.precompile += PRECOMPILE_ASSETS + if ReActionView.config.development? && app.config.respond_to?(:assets) + if app.config.assets.respond_to?(:precompile) + app.config.assets.precompile += PRECOMPILE_ASSETS + end end end From 83726df97c144ffb52b6bd7bc19513d77aae0588 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 8 Aug 2026 04:44:49 +0200 Subject: [PATCH 5/7] Revert "Fix: Use Rails::Engine for Propshaft compatibility" This reverts commit 2eee4b8e6674c3c789c012f2c26a9275baa83171. --- lib/reactionview.rb | 2 +- lib/reactionview/{engine.rb => railtie.rb} | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) rename lib/reactionview/{engine.rb => railtie.rb} (59%) diff --git a/lib/reactionview.rb b/lib/reactionview.rb index 4b5e4dd..aeee3fe 100644 --- a/lib/reactionview.rb +++ b/lib/reactionview.rb @@ -21,7 +21,7 @@ require_relative "reactionview/template/handlers/herb" require_relative "reactionview/template/handlers/herb/herb" -require_relative "reactionview/engine" if defined?(Rails::Engine) +require_relative "reactionview/railtie" if defined?(Rails::Railtie) module ReActionView end diff --git a/lib/reactionview/engine.rb b/lib/reactionview/railtie.rb similarity index 59% rename from lib/reactionview/engine.rb rename to lib/reactionview/railtie.rb index 06ed212..6265b73 100644 --- a/lib/reactionview/engine.rb +++ b/lib/reactionview/railtie.rb @@ -1,18 +1,25 @@ # frozen_string_literal: true module ReActionView - class Engine < Rails::Engine + class Railtie < Rails::Railtie + # If you don't want to precompile ReActionView's assets (eg. because you're using propshaft), + # you can do this in an initializer: + # + # config.after_initialize do + # config.assets.precompile -= ReActionView::Railtie::PRECOMPILE_ASSETS + # end + # PRECOMPILE_ASSETS = %w[ reactionview-dev-tools.esm.js reactionview-dev-tools.umd.js ].freeze initializer "reactionview.assets" do |app| - # Sprockets precompilation config (for backward compatibility) if ReActionView.config.development? && app.config.respond_to?(:assets) - if app.config.assets.respond_to?(:precompile) - app.config.assets.precompile += PRECOMPILE_ASSETS - end + gem_root = Gem::Specification.find_by_name("reactionview").gem_dir + + app.config.assets.paths << File.join(gem_root, "app", "assets", "javascripts") + app.config.assets.precompile += PRECOMPILE_ASSETS end end From 0358632848080aeebdb9e2510e822cff45320c6a Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 8 Aug 2026 04:39:54 +0200 Subject: [PATCH 6/7] Explain `Propshaft::MissingAssetError` from a stale asset manifest --- lib/reactionview/asset_manifest.rb | 88 +++++++ .../middleware/asset_manifest_check.rb | 94 ++++++++ lib/reactionview/railtie.rb | 8 + .../stale_asset_manifest_error.rb | 9 + .../middleware/asset_manifest_check_test.rb | 218 ++++++++++++++++++ 5 files changed, 417 insertions(+) create mode 100644 lib/reactionview/asset_manifest.rb create mode 100644 lib/reactionview/middleware/asset_manifest_check.rb create mode 100644 lib/reactionview/stale_asset_manifest_error.rb create mode 100644 test/reactionview/middleware/asset_manifest_check_test.rb diff --git a/lib/reactionview/asset_manifest.rb b/lib/reactionview/asset_manifest.rb new file mode 100644 index 0000000..c837779 --- /dev/null +++ b/lib/reactionview/asset_manifest.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "json" + +module ReActionView + class AssetManifest + def initialize(assets) + @assets = assets + end + + def path + config = @assets&.config + + return unless config.respond_to?(:manifest_path) + + config.manifest_path + end + + def mtime + return if path.nil? + + File.mtime(path) + rescue SystemCallError + nil + end + + def exist? + !mtime.nil? + end + + def missing_assets + precompiled = logical_paths + + return [] if precompiled.empty? + + ReActionView::Railtie::PRECOMPILE_ASSETS - precompiled + end + + def stale? + exist? && !missing_assets.empty? + end + + def in_use? + @assets.respond_to?(:resolver) && @assets.resolver.respond_to?(:manifest_path) + end + + def explanation + <<~MESSAGE + ReActionView's dev tools assets are missing from the precompiled asset manifest. + #{status} + + To fix this, delete the precompiled assets: + + bin/rails assets:clobber + + That is safe in development. Without a manifest, Propshaft serves assets straight from the + load path, where these files already are. + + Missing: #{missing_assets.join(", ")} + Manifest: #{path} + + The manifest was most likely left behind by `RAILS_ENV=production bin/rails assets:precompile`. + A production precompile doesn't include ReActionView's dev tools, and Propshaft stops scanning + the load path as soon as a manifest exists. + MESSAGE + end + + private + + def status + if in_use? + "Propshaft is resolving assets from that manifest instead of the load path, which is why the\n" \ + "lookup failed." + else + "This server booted before the manifest existed, so pages still render for now. Propshaft will\n" \ + "resolve assets from the manifest after the next restart, and rendering will fail then." + end + end + + def logical_paths + return [] if path.nil? + + JSON.parse(File.read(path)).keys + rescue SystemCallError, JSON::ParserError + [] + end + end +end diff --git a/lib/reactionview/middleware/asset_manifest_check.rb b/lib/reactionview/middleware/asset_manifest_check.rb new file mode 100644 index 0000000..56fe8ac --- /dev/null +++ b/lib/reactionview/middleware/asset_manifest_check.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require_relative "../asset_manifest" +require_relative "../stale_asset_manifest_error" + +module ReActionView + module Middleware + class AssetManifestCheck + def initialize(app, assets: nil, logger: nil) + @app = app + @assets = assets + @logger = logger + @mutex = Mutex.new + @checked_mtime = nil + end + + def call(env) + warn_about_upcoming_failure + + @app.call(env) + rescue StandardError => e + raise unless dev_tools_asset_missing?(e) + + manifest = self.manifest + + raise unless manifest.stale? + + raise StaleAssetManifestError.for(manifest) + end + + private + + def assets + @assets || (defined?(Rails) && Rails.application&.assets) + end + + def logger + @logger || (defined?(Rails) && Rails.logger) + end + + def manifest + AssetManifest.new(assets) + end + + def warn_about_upcoming_failure + manifest = self.manifest + mtime = manifest.mtime + + return if mtime.nil? + + @mutex.synchronize do + return if @checked_mtime == mtime + + @checked_mtime = mtime + + return if manifest.in_use? || !manifest.stale? + + log("[ReActionView] #{manifest.explanation}") + end + end + + def dev_tools_asset_missing?(error) + error_class = missing_asset_error_class + + return false if error_class.nil? + + while error + if error.is_a?(error_class) && + ReActionView::Railtie::PRECOMPILE_ASSETS.any? { |asset| error.message.include?(asset) } + return true + end + + error = error.cause + end + + false + end + + def missing_asset_error_class + return unless defined?(::Propshaft::MissingAssetError) + + ::Propshaft::MissingAssetError + end + + def log(message) + if logger + logger.warn(message) + else + warn(message) + end + end + end + end +end diff --git a/lib/reactionview/railtie.rb b/lib/reactionview/railtie.rb index 6265b73..801c387 100644 --- a/lib/reactionview/railtie.rb +++ b/lib/reactionview/railtie.rb @@ -23,6 +23,14 @@ class Railtie < Rails::Railtie end end + initializer "reactionview.asset_manifest_check" do |app| + next unless ReActionView.config.development? + + require_relative "middleware/asset_manifest_check" + + app.middleware.use ReActionView::Middleware::AssetManifestCheck + end + initializer "reactionview.register_herb_handler" do ActiveSupport.on_load(:action_view) do ActionView::Template.register_template_handler :herb, ReActionView::Template::Handlers::Herb diff --git a/lib/reactionview/stale_asset_manifest_error.rb b/lib/reactionview/stale_asset_manifest_error.rb new file mode 100644 index 0000000..ab2dd25 --- /dev/null +++ b/lib/reactionview/stale_asset_manifest_error.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ReActionView + class StaleAssetManifestError < StandardError + def self.for(manifest) + new(manifest.explanation) + end + end +end diff --git a/test/reactionview/middleware/asset_manifest_check_test.rb b/test/reactionview/middleware/asset_manifest_check_test.rb new file mode 100644 index 0000000..bb45cbf --- /dev/null +++ b/test/reactionview/middleware/asset_manifest_check_test.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +require "reactionview/middleware/asset_manifest_check" + +require "json" +require "tmpdir" + +unless defined?(Propshaft::MissingAssetError) + module Propshaft + class MissingAssetError < StandardError; end + end +end + +class ReActionView::Middleware::AssetManifestCheckTest < Minitest::Spec + class TestLogger + attr_reader :messages + + def initialize + @messages = [] + end + + def warn(message) + @messages << message + end + end + + StaticResolver = Struct.new(:manifest_path) + DynamicResolver = Struct.new(:load_path) + AssetsConfig = Struct.new(:manifest_path, keyword_init: true) + Assets = Struct.new(:config, :resolver, keyword_init: true) + + around do |test| + Dir.mktmpdir do |dir| + @dir = dir + test.call + end + end + + def manifest_path + File.join(@dir, ".manifest.json") + end + + def write_manifest(logical_paths) + entries = logical_paths.to_h { |path| [path, { "digested_path" => path, "integrity" => nil }] } + + File.write(manifest_path, JSON.generate(entries)) + end + + def build_assets(resolver: StaticResolver.new(manifest_path)) + Assets.new(config: AssetsConfig.new(manifest_path: manifest_path), resolver: resolver) + end + + def missing_asset_error(asset = "reactionview-dev-tools.umd.js") + Propshaft::MissingAssetError.new("The asset '#{asset}' was not found in the load path.") + end + + def wrapped(error) + raise error + rescue StandardError + begin + raise ActionView::Template::Error, "template blew up" + rescue ActionView::Template::Error => wrapper + wrapper + end + end + + def build_middleware(assets: build_assets, logger: TestLogger.new, app: ->(_env) { [200, {}, ["ok"]] }) + ReActionView::Middleware::AssetManifestCheck.new(app, assets: assets, logger: logger) + end + + describe "turning the Propshaft error into an explanation" do + test "replaces the error when the manifest is missing the dev tools assets" do + write_manifest(["application.js"]) + middleware = build_middleware(app: ->(_env) { raise missing_asset_error }) + + error = assert_raises(ReActionView::StaleAssetManifestError) { middleware.call({}) } + + assert_includes error.message, "reactionview-dev-tools.umd.js" + assert_includes error.message, "reactionview-dev-tools.esm.js" + assert_includes error.message, manifest_path + assert_includes error.message, "bin/rails assets:clobber" + end + + test "keeps the original error available as the cause" do + write_manifest(["application.js"]) + original = missing_asset_error + middleware = build_middleware(app: ->(_env) { raise original }) + + error = assert_raises(ReActionView::StaleAssetManifestError) { middleware.call({}) } + + assert_same original, error.cause + end + + test "finds the Propshaft error when ActionView wrapped it" do + write_manifest(["application.js"]) + wrapper = wrapped(missing_asset_error) + middleware = build_middleware(app: ->(_env) { raise wrapper }) + + assert_raises(ReActionView::StaleAssetManifestError) { middleware.call({}) } + end + + test "leaves the error alone when the missing asset is not ours" do + write_manifest(["application.js"]) + middleware = build_middleware(app: ->(_env) { raise missing_asset_error("some-other-asset.js") }) + + assert_raises(Propshaft::MissingAssetError) { middleware.call({}) } + end + + test "leaves the error alone when no manifest explains it" do + middleware = build_middleware(app: ->(_env) { raise missing_asset_error }) + + assert_raises(Propshaft::MissingAssetError) { middleware.call({}) } + end + + test "leaves the error alone when the manifest does contain the dev tools assets" do + write_manifest(["application.js"] + ReActionView::Railtie::PRECOMPILE_ASSETS) + middleware = build_middleware(app: ->(_env) { raise missing_asset_error }) + + assert_raises(Propshaft::MissingAssetError) { middleware.call({}) } + end + + test "leaves unrelated errors alone" do + write_manifest(["application.js"]) + middleware = build_middleware(app: ->(_env) { raise ArgumentError, "boom" }) + + assert_raises(ArgumentError) { middleware.call({}) } + end + end + + describe "warning before the manifest takes effect" do + test "warns when a stale manifest is not in use yet" do + write_manifest(["application.js"]) + logger = TestLogger.new + + build_middleware(assets: build_assets(resolver: DynamicResolver.new([])), logger: logger).call({}) + + assert_equal 1, logger.messages.size + assert_includes logger.messages.first, "booted before the manifest existed" + assert_includes logger.messages.first, manifest_path + assert_includes logger.messages.first, "bin/rails assets:clobber" + end + + test "stays quiet when the manifest is already in use, since rendering raises instead" do + write_manifest(["application.js"]) + logger = TestLogger.new + + build_middleware(assets: build_assets(resolver: StaticResolver.new(manifest_path)), logger: logger).call({}) + + assert_empty logger.messages + end + + test "stays quiet when the manifest contains the dev tools assets" do + write_manifest(["application.js"] + ReActionView::Railtie::PRECOMPILE_ASSETS) + logger = TestLogger.new + + build_middleware(assets: build_assets(resolver: DynamicResolver.new([])), logger: logger).call({}) + + assert_empty logger.messages + end + + test "stays quiet when no manifest exists" do + logger = TestLogger.new + + build_middleware(assets: build_assets(resolver: DynamicResolver.new([])), logger: logger).call({}) + + assert_empty logger.messages + end + + test "stays quiet when the asset pipeline has no manifest path" do + logger = TestLogger.new + sprockets_like_assets = Struct.new(:config).new({}) + + build_middleware(assets: sprockets_like_assets, logger: logger).call({}) + + assert_empty logger.messages + end + + test "stays quiet when there is no asset pipeline" do + logger = TestLogger.new + + build_middleware(assets: nil, logger: logger).call({}) + + assert_empty logger.messages + end + + test "warns only once while the manifest is unchanged" do + write_manifest(["application.js"]) + logger = TestLogger.new + middleware = build_middleware(assets: build_assets(resolver: DynamicResolver.new([])), logger: logger) + + 3.times { middleware.call({}) } + + assert_equal 1, logger.messages.size + end + + test "warns again after the manifest changes" do + write_manifest(["application.js"]) + logger = TestLogger.new + middleware = build_middleware(assets: build_assets(resolver: DynamicResolver.new([])), logger: logger) + + middleware.call({}) + + File.utime(Time.now + 5, Time.now + 5, manifest_path) + + middleware.call({}) + + assert_equal 2, logger.messages.size + end + end + + test "passes the request through to the app" do + write_manifest(["application.js"]) + + assert_equal [200, {}, ["ok"]], build_middleware.call({}) + end +end From 2e78995be8f3f76b536a237f7fc61dd52827332c Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 8 Aug 2026 04:54:35 +0200 Subject: [PATCH 7/7] Tweak message --- lib/reactionview/asset_manifest.rb | 20 +++++++++---------- .../middleware/asset_manifest_check_test.rb | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/reactionview/asset_manifest.rb b/lib/reactionview/asset_manifest.rb index c837779..0ad2ef2 100644 --- a/lib/reactionview/asset_manifest.rb +++ b/lib/reactionview/asset_manifest.rb @@ -46,22 +46,21 @@ def in_use? def explanation <<~MESSAGE - ReActionView's dev tools assets are missing from the precompiled asset manifest. + ReActionView's dev tools assets are missing from your precompiled assets. #{status} To fix this, delete the precompiled assets: bin/rails assets:clobber - That is safe in development. Without a manifest, Propshaft serves assets straight from the - load path, where these files already are. + That is safe in development. Without public/assets/, Propshaft serves each asset straight from + the directory it lives in, including ReActionView's. Missing: #{missing_assets.join(", ")} Manifest: #{path} - The manifest was most likely left behind by `RAILS_ENV=production bin/rails assets:precompile`. - A production precompile doesn't include ReActionView's dev tools, and Propshaft stops scanning - the load path as soon as a manifest exists. + public/assets/ was most likely left behind by `RAILS_ENV=production bin/rails assets:precompile`, + and a production precompile doesn't include ReActionView's dev tools. MESSAGE end @@ -69,11 +68,12 @@ def explanation def status if in_use? - "Propshaft is resolving assets from that manifest instead of the load path, which is why the\n" \ - "lookup failed." + "Because public/assets/.manifest.json exists, Propshaft serves every asset from public/assets/\n" \ + "and doesn't look anywhere else, which is why the lookup failed." else - "This server booted before the manifest existed, so pages still render for now. Propshaft will\n" \ - "resolve assets from the manifest after the next restart, and rendering will fail then." + "This server booted before public/assets/.manifest.json existed, so pages still render for now.\n" \ + "After the next restart Propshaft will serve assets from public/assets/ only, and rendering\n" \ + "will fail then." end end diff --git a/test/reactionview/middleware/asset_manifest_check_test.rb b/test/reactionview/middleware/asset_manifest_check_test.rb index bb45cbf..6ba70c4 100644 --- a/test/reactionview/middleware/asset_manifest_check_test.rb +++ b/test/reactionview/middleware/asset_manifest_check_test.rb @@ -137,7 +137,7 @@ def build_middleware(assets: build_assets, logger: TestLogger.new, app: ->(_env) build_middleware(assets: build_assets(resolver: DynamicResolver.new([])), logger: logger).call({}) assert_equal 1, logger.messages.size - assert_includes logger.messages.first, "booted before the manifest existed" + assert_includes logger.messages.first, "booted before public/assets/.manifest.json existed" assert_includes logger.messages.first, manifest_path assert_includes logger.messages.first, "bin/rails assets:clobber" end