From 9eb095571702870a9d119ef11f922c0e56870ce6 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 9 Aug 2026 09:47:33 -0700 Subject: [PATCH 1/3] fix: render batched components as HTML The batch endpoint is requested with a JSON Accept header, so Rails resolved partials against the JSON format, raised ActionView::MissingTemplate, and the controller converted that into a 404 for any application whose components are ordinary .html.erb partials. Render component partials with an explicit HTML format while the outer response stays JSON. Single-component refresh sends an HTML Accept header and was never affected. Also pass registrations to component_authorization_context, one for a single refresh and all of them for a batch, so applications no longer decode params[:tokens] by hand; callbacks accepting only controller: are detected and called unchanged. --- CHANGELOG.md | 13 + .../solid_objects/components_controller.rb | 30 +- docs/realtime.md | 14 + lib/solid_objects/component_renderer.rb | 1 + .../solid_objects/components_controller.rbs | 9 + .../component_batch_controller_test.rb | 264 ++++++++++++++++++ 6 files changed, 323 insertions(+), 8 deletions(-) create mode 100644 test/integration/component_batch_controller_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index f57ebda..8f9ff58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## Unreleased + +- Render batched component partials as HTML regardless of the request format. + The batch endpoint is requested with a JSON `Accept` header, so Rails looked + for JSON templates, raised `ActionView::MissingTemplate`, and the batch + returned 404 for applications whose components are ordinary + `.html.erb` partials. The outer response is still JSON. Single-component + refresh was never affected and is unchanged. +- Pass `registrations:` to `component_authorization_context`: one registration + for a single refresh, all of them for a batch, so applications no longer have + to inspect `params[:tokens]`. Callbacks accepting only `controller:` keep + working unchanged. + ## 0.7.1 - 2026-08-09 - Retry a contended SQLite write outside a synchronous deadline. Asynchronous diff --git a/app/controllers/solid_objects/components_controller.rb b/app/controllers/solid_objects/components_controller.rb index 40514d0..94c97d4 100644 --- a/app/controllers/solid_objects/components_controller.rb +++ b/app/controllers/solid_objects/components_controller.rb @@ -43,10 +43,7 @@ def refresh_batch(payload) return head :conflict end - authorization_context = SolidObjects - .configuration - .component_authorization_context - .call(controller: self) + authorization_context = component_authorization_context(registrations) frames = registrations.map do |registration| rendered = ComponentRenderer.new( snapshot:, @@ -112,10 +109,7 @@ def refresh(payload) return head :conflict end - authorization_context = SolidObjects - .configuration - .component_authorization_context - .call(controller: self) + authorization_context = component_authorization_context([ registration ]) rendered = ComponentRenderer.new( snapshot:, registration:, @@ -138,6 +132,26 @@ def refresh(payload) head :bad_request end + # Callbacks written before batching accept only `controller:`. Those keep + # working; a callback that also accepts `registrations:` receives one + # registration for a single refresh and all of them for a batch. + # @rbs (Array[ComponentRegistration]) -> untyped + def component_authorization_context(registrations) + callable = SolidObjects.configuration.component_authorization_context + return callable.call(controller: self) unless accepts_registrations?(callable) + + callable.call(controller: self, registrations:) + end + + # @rbs (untyped) -> bool + def accepts_registrations?(callable) + return false unless callable.respond_to?(:parameters) + + callable.parameters.any? do |type, name| + type == :keyrest || (%i[key keyreq].include?(type) && name == :registrations) + end + end + # @rbs (ComponentRegistration) -> Hash[Symbol, untyped] def registration_payload(registration) { diff --git a/docs/realtime.md b/docs/realtime.md index bfb1e1f..09fe015 100644 --- a/docs/realtime.md +++ b/docs/realtime.md @@ -290,6 +290,20 @@ commonly resolve it to `Current.user`: configuration.component_authorization_context = ->(controller:) { Current.user } ``` +A callback may also accept `registrations:`, which receives one registration for +a single component refresh and every registration in the group for a batch +refresh. This avoids decoding `params[:tokens]` by hand when a policy depends on +which components were requested: + +```ruby +configuration.component_authorization_context = lambda do |controller:, registrations:| + Current.user if registrations.all? { |registration| registration.component_key == controller.session[:seat] } +end +``` + +Callbacks that accept only `controller:` continue to work; the extra keyword is +passed only to callables that declare it. + The three contexts are intentionally different: | Boundary | Authorization context | diff --git a/lib/solid_objects/component_renderer.rb b/lib/solid_objects/component_renderer.rb index 0671889..7ed6334 100644 --- a/lib/solid_objects/component_renderer.rb +++ b/lib/solid_objects/component_renderer.rb @@ -32,6 +32,7 @@ def call ) view_context.render( partial: default_partial, + formats: [ :html ], locals: registration.locals.transform_keys(&:to_sym).merge( actor:, authorization_context:, diff --git a/sig/generated/controllers/solid_objects/components_controller.rbs b/sig/generated/controllers/solid_objects/components_controller.rbs index 3881165..a3eb728 100644 --- a/sig/generated/controllers/solid_objects/components_controller.rbs +++ b/sig/generated/controllers/solid_objects/components_controller.rbs @@ -21,6 +21,15 @@ module SolidObjects # @rbs (Hash[Symbol, untyped]) -> void def refresh: (Hash[Symbol, untyped]) -> void + # Callbacks written before batching accept only `controller:`. Those keep + # working; a callback that also accepts `registrations:` receives one + # registration for a single refresh and all of them for a batch. + # @rbs (Array[ComponentRegistration]) -> untyped + def component_authorization_context: (Array[ComponentRegistration]) -> untyped + + # @rbs (untyped) -> bool + def accepts_registrations?: (untyped) -> bool + # @rbs (ComponentRegistration) -> Hash[Symbol, untyped] def registration_payload: (ComponentRegistration) -> Hash[Symbol, untyped] diff --git a/test/integration/component_batch_controller_test.rb b/test/integration/component_batch_controller_test.rb new file mode 100644 index 0000000..21e1609 --- /dev/null +++ b/test/integration/component_batch_controller_test.rb @@ -0,0 +1,264 @@ +# frozen_string_literal: true + +require "database_test_helper" +require "action_controller" +require "action_controller/test_case" +require "action_view" +require "action_view/testing/resolvers" +require_relative "../../app/controllers/solid_objects/components_controller" + +class ComponentBatchControllerTest < ActionController::TestCase + tests SolidObjects::ComponentsController + + class PlaymatRoom < SolidObjects::Actor + actor_type "batch-controller-playmat" + + attribute :player, default: "alice" + attribute :controls, default: -> { %w[untap draw] } + attribute :library, default: -> { %w[Island] } + + observable :player + observable :controls + observable :library + + def seat(player:) + self.player = player + end + end + + setup do + @routes = ActionDispatch::Routing::RouteSet.new + @routes.draw do + get "components", to: "solid_objects/components#show" + get "components/batch", to: "solid_objects/components#batch" + end + @controller.prepend_view_path( + ActionView::FixtureResolver.new( + "actors/component_batch_controller_test/playmat_room/_player.html.erb" => + "

<%= actor.player %>

", + "actors/component_batch_controller_test/playmat_room/_controls.html.erb" => + "", + "actors/component_batch_controller_test/playmat_room/_library.html.erb" => + "
\" data-seat=\"<%= seat %>\"><%= actor.library.join(\",\") %>
" + ) + ) + SolidObjects.configuration.stream_signing_secret = "batch-controller-secret" + SolidObjects.configuration.component_authorization_context = lambda do |controller:| + controller.request.headers["HTTP_X_VIEWER"] + end + PlaymatRoom.ensure_registered! + end + + test "renders an HTML ERB component through the JSON batch endpoint" do + registrations = issue(player: %w[player]) + + get_batch(registrations) + + assert_response :success + assert_equal "application/json", response.media_type + frame = json_body.fetch("frames").first + assert_includes frame.fetch("html"), "data-player" + assert_includes frame.fetch("html"), "alice" + end + + test "renders several components in one batch" do + registrations = issue(player: %w[player], controls: %w[controls]) + + get_batch(registrations) + + assert_response :success + frames = json_body.fetch("frames") + assert_equal 2, frames.length + assert_includes frames.first.fetch("html"), "data-player" + assert_includes frames.last.fetch("html"), "
  • untap
  • " + end + + test "renders a component with locals and dependencies" do + registrations = issue(library: %w[library]) + + get_batch(registrations) + + assert_response :success + html = json_body.fetch("frames").first.fetch("html") + assert_includes html, %(data-viewer="alice") + assert_includes html, %(data-seat="north") + assert_includes html, "Island" + end + + test "the request format stays JSON while partials render as HTML" do + registrations = issue(player: %w[player]) + + get_batch(registrations) + + assert_equal "application/json", response.media_type + assert_includes json_body.fetch("frames").first.fetch("html"), " Date: Sun, 9 Aug 2026 09:48:54 -0700 Subject: [PATCH 2/3] chore: prepare 0.7.2 release --- CHANGELOG.md | 2 +- Gemfile.lock | 4 ++-- lib/solid_objects/version.rb | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f9ff58..d748255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.7.2 - 2026-08-09 - Render batched component partials as HTML regardless of the request format. The batch endpoint is requested with a JSON `Accept` header, so Rails looked diff --git a/Gemfile.lock b/Gemfile.lock index 4fe8109..ea1fd79 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - solid_objects (0.7.1) + solid_objects (0.7.2) actioncable (>= 8.0) actionpack (>= 8.0) actionview (>= 8.0) @@ -373,7 +373,7 @@ CHECKSUMS rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 - solid_objects (0.7.1) + solid_objects (0.7.2) sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b diff --git a/lib/solid_objects/version.rb b/lib/solid_objects/version.rb index 1adf97b..64a53f7 100644 --- a/lib/solid_objects/version.rb +++ b/lib/solid_objects/version.rb @@ -1,5 +1,5 @@ # rbs_inline: enabled module SolidObjects - VERSION = "0.7.1" + VERSION = "0.7.2" end From 0d04645bcf89910deefd464cb8c54134328ab67b Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 9 Aug 2026 09:52:44 -0700 Subject: [PATCH 3/3] fix: detect registrations on callable objects Capability detection asked the configured object for its parameters, which a lambda answers but a callable object does not, so an object whose call method requires registrations: was invoked with only controller: and raised ArgumentError. Read parameters from the call method when the object does not expose them directly. This repository already accepts callable objects for configuration, as component_path_resolver does. --- .../solid_objects/components_controller.rb | 14 ++++++-- .../solid_objects/components_controller.rbs | 5 +++ .../component_batch_controller_test.rb | 36 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/app/controllers/solid_objects/components_controller.rb b/app/controllers/solid_objects/components_controller.rb index 94c97d4..36034da 100644 --- a/app/controllers/solid_objects/components_controller.rb +++ b/app/controllers/solid_objects/components_controller.rb @@ -143,15 +143,23 @@ def component_authorization_context(registrations) callable.call(controller: self, registrations:) end + # A lambda answers `parameters` directly; a callable object answers it + # through its `call` method. # @rbs (untyped) -> bool def accepts_registrations?(callable) - return false unless callable.respond_to?(:parameters) - - callable.parameters.any? do |type, name| + callable_parameters(callable).any? do |type, name| type == :keyrest || (%i[key keyreq].include?(type) && name == :registrations) end end + # @rbs (untyped) -> Array[[ Symbol, Symbol ]] + def callable_parameters(callable) + return callable.parameters if callable.respond_to?(:parameters) + return callable.method(:call).parameters if callable.respond_to?(:call) + + [] + end + # @rbs (ComponentRegistration) -> Hash[Symbol, untyped] def registration_payload(registration) { diff --git a/sig/generated/controllers/solid_objects/components_controller.rbs b/sig/generated/controllers/solid_objects/components_controller.rbs index a3eb728..a96add9 100644 --- a/sig/generated/controllers/solid_objects/components_controller.rbs +++ b/sig/generated/controllers/solid_objects/components_controller.rbs @@ -27,9 +27,14 @@ module SolidObjects # @rbs (Array[ComponentRegistration]) -> untyped def component_authorization_context: (Array[ComponentRegistration]) -> untyped + # A lambda answers `parameters` directly; a callable object answers it + # through its `call` method. # @rbs (untyped) -> bool def accepts_registrations?: (untyped) -> bool + # @rbs (untyped) -> Array[[ Symbol, Symbol ]] + def callable_parameters: (untyped) -> Array[[ Symbol, Symbol ]] + # @rbs (ComponentRegistration) -> Hash[Symbol, untyped] def registration_payload: (ComponentRegistration) -> Hash[Symbol, untyped] diff --git a/test/integration/component_batch_controller_test.rb b/test/integration/component_batch_controller_test.rb index 21e1609..4d280d2 100644 --- a/test/integration/component_batch_controller_test.rb +++ b/test/integration/component_batch_controller_test.rb @@ -10,6 +10,21 @@ class ComponentBatchControllerTest < ActionController::TestCase tests SolidObjects::ComponentsController + class RegistrationAwareResolver + attr_reader :received + + def call(controller:, registrations:) + @received = registrations + controller.request.headers["HTTP_X_VIEWER"] + end + end + + class ControllerOnlyResolver + def call(controller:) + controller.request.headers["HTTP_X_VIEWER"] + end + end + class PlaymatRoom < SolidObjects::Actor actor_type "batch-controller-playmat" @@ -208,6 +223,27 @@ def seat(player:) assert_equal %w[player], received.map(&:component_name) end + test "a callable object requiring registrations receives them" do + resolver = RegistrationAwareResolver.new + SolidObjects.configuration.component_authorization_context = resolver + registrations = issue(player: %w[player], controls: %w[controls]) + + get_batch(registrations) + + assert_response :success + assert_equal %w[player controls], resolver.received.map(&:component_name) + end + + test "a callable object accepting only controller keeps working" do + SolidObjects.configuration.component_authorization_context = ControllerOnlyResolver.new + registrations = issue(player: %w[player]) + + get_batch(registrations) + + assert_response :success + assert_includes json_body.fetch("frames").first.fetch("html"), "data-player" + end + test "single component refresh still renders HTML" do registration = issue(player: %w[player]).first @request.headers["HTTP_X_VIEWER"] = "alice"