diff --git a/CHANGELOG.md b/CHANGELOG.md index 732c7fc..e81029a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.5.0 - 2026-08-07 + +- Add repeatable reactive components with signed string or integer keys and + JSON-compatible partial locals. +- Add opt-in Turbo morph refreshes with superseded-request cancellation and + browser-side actor revision fencing. +- Pass signed component keys and locals through request-time query + authorization without broadcasting personalized HTML. + ## 0.4.3 - 2026-08-07 - Bound SQLite caller-process registration, reuse, heartbeat, and synchronous diff --git a/Gemfile.lock b/Gemfile.lock index 1a68f79..00cf7a9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - solid_objects (0.4.3) + solid_objects (0.5.0) 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.4.3) + solid_objects (0.5.0) 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/README.md b/README.md index f737a3f..1600496 100644 --- a/README.md +++ b/README.md @@ -205,9 +205,42 @@ dependencies changes: <% end %> ``` +Component names can repeat when each instance has a stable key. Signed +JSON-compatible locals let one conventional partial render the matching +projection: + +```erb +<%= solid_object @room, authorization_context: current_user do |room| %> + <% @players.each do |player| %> + <%= room.component :player, + key: player.id, + observes: %i[players life_totals], + locals: { player_id: player.id }, + refresh: :morph %> + <% end %> +<% end %> +``` + +The host partial still resolves only to `actors/chat_room/_player`. It receives +`actor`, `authorization_context`, `component_key`, and the declared locals: + +```erb +
+ Life: <%= actor.life_totals.fetch(player_id.to_s) %> +
+``` + +The default refresh strategy is `:replace`. `refresh: :morph` loads the +authorized component HTML through a gem-owned browser element, rejects stale +responses by actor revision, and applies the result using Turbo's scoped +`replace method="morph"`. Superseded requests for the same keyed target are +aborted. This preserves unchanged DOM nodes where Turbo's morphing rules allow +it, including focus and `data-turbo-permanent` content. + `room.component(:messages)` resolves only `actors/chat_room/_messages`. Its partial receives `actor` and -`authorization_context` locals: +`authorization_context` locals, plus a `component_key` of `nil` when the +component is unkeyed: ```erb ERB - "actors/components_controller_test/room_actor/_presence.html.erb" => <<~ERB + "actors/components_controller_test/room_actor/_presence.html.erb" => <<~ERB, <% if actor.status == "closed" %>

Room closed

<% else %>

<%= actor.recent_messages.length %> present

<% end %> ERB + "actors/components_controller_test/room_actor/_player.html.erb" => <<~ERB +
+ <%= label %>: <%= actor.status %> +
+ ERB ) ) SolidObjects.configuration.stream_signing_secret = "test-stream-signing-secret" @@ -235,9 +240,68 @@ def update_room(messages:, status:) assert_empty @response.body end + test "renders signed keyed locals and passes them to authorization" do + reference = RoomActor.ref("general") + authorization_calls = [] + SolidObjects.configuration.authorize_query = lambda do |**arguments| + authorization_calls << arguments + arguments.fetch(:arguments).fetch("player_id") == "alice" + end + token = component_token( + reference, + component_name: "player", + component_key: "alice", + dependencies: %w[status], + locals: { + player_id: "alice", + label: "You" + } + ) + + render_component(token, viewer: "alice") + + assert_response :success + assert_includes @response.body, %(data-player-id="alice") + assert_includes @response.body, %(data-component-key="alice") + assert_includes @response.body, "You: open" + assert_equal 2, authorization_calls.length + expected_arguments = { + "player_id" => "alice", + "label" => "You", + "component_key" => "alice" + } + assert authorization_calls.all? { |arguments| + arguments.fetch(:arguments) == expected_arguments + } + end + + test "returns morph metadata for a morph component refresh" do + reference = RoomActor.ref("general") + token = component_token( + reference, + component_name: "player", + component_key: "alice", + dependencies: %w[status], + locals: { player_id: "alice", label: "You" }, + refresh_method: "morph" + ) + + render_component(token, viewer: "alice") + + assert_response :success + assert_includes @response.body, %(data-solid-objects-refresh="morph") + end + private - def component_token(reference, component_name:, dependencies:) + def component_token( + reference, + component_name:, + dependencies:, + component_key: nil, + locals: {}, + refresh_method: "replace" + ) instance = SolidObjects::Instance.find_by( actor_type: reference.actor_type, actor_id: reference.actor_id @@ -245,7 +309,10 @@ def component_token(reference, component_name:, dependencies:) SolidObjects::ComponentToken.generate( reference:, component_name:, + component_key:, dependencies:, + locals:, + refresh_method:, instance_id: instance&.id || 0, revision: instance&.state_revision || 0, refresh_path: "/components" diff --git a/test/integration/engine_test.rb b/test/integration/engine_test.rb index a065ec5..0df49cc 100644 --- a/test/integration/engine_test.rb +++ b/test/integration/engine_test.rb @@ -27,4 +27,13 @@ class EngineTest < ActiveSupport::TestCase assert status.success?, error_output assert_equal "/solid_objects/components", output.strip end + + test "packages the morph refresh browser module" do + specification = Gem::Specification.load( + File.expand_path("../../solid_objects.gemspec", __dir__) + ) + + assert_includes specification.files, + "app/assets/javascripts/solid_objects/component_refresh.js" + end end diff --git a/test/integration/example_chat_room_test.rb b/test/integration/example_chat_room_test.rb index dc8dccf..4306ff1 100644 --- a/test/integration/example_chat_room_test.rb +++ b/test/integration/example_chat_room_test.rb @@ -29,7 +29,11 @@ class ExampleChatRoomTest < ActionView::TestCase ) html = solid_object(room, authorization_context: "alice") do |actor| - actor.component(:messages, observes: :recent_messages) + actor.component( + :messages, + observes: :recent_messages, + refresh: :morph + ) end assert_includes html, %(id="message_message-1") @@ -37,5 +41,7 @@ class ExampleChatRoomTest < ActionView::TestCase assert_includes html, "<Hello>" refute_includes html, JSON.generate(room.snapshot.recent_messages) assert_equal 1, html.scan(" "alice", + "seat" => 1 + }, + registration.locals + ) + assert_equal "morph", registration.refresh_method + assert_match(/_component_player_/, registration.dom_id) + refute_includes registration.dom_id, "alice" + end + + test "rejects invalid component keys locals and refresh methods" do + invalid_options = [ + { component_key: true }, + { locals: { actor: "shadowed" } }, + { locals: { "invalid-name" => "value" } }, + { refresh_method: "append" } + ] + + invalid_options.each do |options| + assert_raises(SolidObjects::InvalidComponentToken) do + SolidObjects::ComponentToken.generate( + reference: RoomActor.ref("general"), + component_name: "messages", + dependencies: %w[messages], + instance_id: 0, + revision: 0, + refresh_path: "/solid_objects/components", + **options + ) + end + end + end + + test "accepts component tokens issued before keyed components" do + payload = { + "actor_type" => "component-token-room", + "actor_id" => "general", + "component_name" => "messages", + "dependencies" => %w[messages], + "instance_id" => 12, + "revision" => 34, + "refresh_path" => "/solid_objects/components" + } + verifier = ActiveSupport::MessageVerifier.new( + "test-stream-signing-secret", + digest: "SHA256", + serializer: JSON + ) + token = verifier.generate( + payload, + purpose: SolidObjects::ComponentToken::PURPOSE + ) + + registration = SolidObjects::ComponentRegistration.from_token(token) + + assert_nil registration.component_key + assert_empty registration.locals + assert_equal "replace", registration.refresh_method + end + test "rejects modified tokens" do token = valid_token