diff --git a/CHANGELOG.md b/CHANGELOG.md index f57ebda..d748255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 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 + 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/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/app/controllers/solid_objects/components_controller.rb b/app/controllers/solid_objects/components_controller.rb index 40514d0..36034da 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,34 @@ 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 + + # A lambda answers `parameters` directly; a callable object answers it + # through its `call` method. + # @rbs (untyped) -> bool + def accepts_registrations?(callable) + 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/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/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 diff --git a/sig/generated/controllers/solid_objects/components_controller.rbs b/sig/generated/controllers/solid_objects/components_controller.rbs index 3881165..a96add9 100644 --- a/sig/generated/controllers/solid_objects/components_controller.rbs +++ b/sig/generated/controllers/solid_objects/components_controller.rbs @@ -21,6 +21,20 @@ 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 + + # 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 new file mode 100644 index 0000000..4d280d2 --- /dev/null +++ b/test/integration/component_batch_controller_test.rb @@ -0,0 +1,300 @@ +# 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 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" + + 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" => + "