Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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
Expand Down
38 changes: 30 additions & 8 deletions app/controllers/solid_objects/components_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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:,
Expand Down Expand Up @@ -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:,
Expand All @@ -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
Comment on lines +149 to +153

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Callable objects lose registrations

When configuration uses a callable object whose call method requires registrations:, capability detection inspects the object rather than its method and invokes it with only controller:, causing an ArgumentError and a server error for component refreshes.

Suggested change
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
def accepts_registrations?(callable)
parameters = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
parameters.any? do |type, name|
type == :keyrest || (%i[key keyreq].include?(type) && name == :registrations)
end
end
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/controllers/solid_objects/components_controller.rb
Line: 147-153

Comment:
**Callable objects lose registrations**

When configuration uses a callable object whose `call` method requires `registrations:`, capability detection inspects the object rather than its method and invokes it with only `controller:`, causing an `ArgumentError` and a server error for component refreshes.

```suggestion
    def accepts_registrations?(callable)
      parameters = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters

      parameters.any? do |type, name|
        type == :keyrest || (%i[key keyreq].include?(type) && name == :registrations)
      end
    end
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 0d04645. A Proc answers parameters; a callable object does not, so my detection fell through to the controller:-only path and an object whose call requires registrations: raised ArgumentError. This is not hypothetical for this repository, which already accepts callable objects for configuration, as component_path_resolver does.

Parameters are now read from the call method when the object does not expose them directly, with a fallback to an empty list so an object exposing neither cannot raise here.

Two tests, one per shape: a callable object requiring registrations: receives them, and one accepting only controller: keeps working. I verified the first genuinely catches the defect by stashing the fix, where it fails, and restoring it, where it passes.


# @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)
{
Expand Down
14 changes: 14 additions & 0 deletions docs/realtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions lib/solid_objects/component_renderer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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:,
Expand Down
2 changes: 1 addition & 1 deletion lib/solid_objects/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# rbs_inline: enabled

module SolidObjects
VERSION = "0.7.1"
VERSION = "0.7.2"
end
14 changes: 14 additions & 0 deletions sig/generated/controllers/solid_objects/components_controller.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
Loading
Loading