From add2f0e45cb37d0f97723a6086f77af1869b26d7 Mon Sep 17 00:00:00 2001
From: Lucas Carlson
Date: Fri, 7 Aug 2026 11:21:06 -0700
Subject: [PATCH] feat: add keyed reactive components
Allow repeated component names behind signed keys, pass safe JSON locals through initial and refresh authorization, and add revision-fenced Turbo morph refreshes without broadcasting personalized HTML.
---
CHANGELOG.md | 9 +
Gemfile.lock | 4 +-
README.md | 61 ++++++-
.../solid_objects/component_refresh.js | 124 ++++++++++++++
.../solid_objects/components_controller.rb | 9 +-
app/helpers/solid_objects/actor_helper.rb | 9 +-
docs/adr/0009-realtime-updates.md | 19 ++-
docs/architecture.md | 52 ++++--
docs/authorization.md | 24 +++
docs/correctness.md | 16 +-
docs/realtime.md | 91 ++++++++--
docs/roadmap.md | 11 +-
.../app/views/chat_rooms/show.html.erb | 4 +-
lib/solid_objects/actor_view.rb | 53 ++++--
lib/solid_objects/component_registration.rb | 77 +++++++--
lib/solid_objects/component_renderer.rb | 31 ++--
lib/solid_objects/component_subscriptions.rb | 14 +-
lib/solid_objects/component_token.rb | 72 +++++++-
lib/solid_objects/dom_identity.rb | 15 +-
lib/solid_objects/engine.rb | 7 +
lib/solid_objects/turbo_stream_renderer.rb | 19 ++-
lib/solid_objects/version.rb | 2 +-
.../lib/solid_objects/actor_view.rbs | 14 +-
.../solid_objects/component_registration.rbs | 44 +++--
.../lib/solid_objects/component_renderer.rbs | 12 +-
.../lib/solid_objects/component_token.rbs | 26 ++-
.../lib/solid_objects/dom_identity.rbs | 7 +-
.../solid_objects/turbo_stream_renderer.rbs | 3 +
test/integration/actor_channel_test.rb | 158 +++++++++++++++++-
test/integration/actor_helper_test.rb | 109 ++++++++++++
.../integration/components_controller_test.rb | 71 +++++++-
test/integration/engine_test.rb | 9 +
test/integration/example_chat_room_test.rb | 8 +-
test/unit/component_token_test.rb | 82 +++++++++
34 files changed, 1111 insertions(+), 155 deletions(-)
create mode 100644 app/assets/javascripts/solid_objects/component_refresh.js
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
@@ -221,7 +254,14 @@ Declared observables are deeply frozen ordinary Ruby values inside a
component. Arrays support loops, hashes support ordinary lookup, conditionals
work normally, and ERB still escapes user strings. A reactive component cannot
read `actor.state`, access an undeclared observable, or choose a dynamic
-partial path.
+partial path. A component name and key pair must be unique within its
+`solid_object` scope.
+
+Component keys and locals are signed into the refresh token and cannot be
+modified without invalidating it, but they are visible to the browser and are
+not secrets. Every initial render and refresh passes the signed locals and
+`component_key` to `authorize_query` as `arguments`. Authorization must still
+bind them to the authenticated request context.
That template provides initial server rendering, stable opaque DOM targets,
and live updates after committed actor turns. One `solid_object` block makes
@@ -254,20 +294,27 @@ for the same actor without sharing either projection.
Reconnect compares the component's signed initial revision with the latest
actor incarnation and state revision, then refreshes stale components. Cable
coalesces several dependency changes from one actor turn into one component
-refresh and ignores older out-of-order invalidations. A newer invalidation
-replaces an in-flight frame, so its detached older response cannot overwrite
-newer state.
+refresh and ignores older out-of-order invalidations. Replace refreshes detach
+an older in-flight frame. Morph refreshes abort the older request and compare
+the returned revision with the current target before applying HTML.
Reactive components add no HTML to durable rows, but each affected component
causes an authorized HTTP render. One actor turn still inserts one broadcast
row per changed observable; several dependencies from that turn coalesce at
the subscriber. Keep components bounded, declare only necessary dependencies,
-and use scalar observables for inexpensive single-value replacement.
+keep signed locals small, and use scalar observables for inexpensive
+single-value replacement. Each keyed component counts toward the 50-component
+subscription limit and carries its own signed token.
Reactive views require `turbo-rails` and a working Action Cable adapter in the
host application. The Solid Objects engine must be mounted so its signed
component endpoint is reachable. Reactive views are optional; the actor
-runtime itself does not depend on Turbo.
+runtime itself does not depend on Turbo. Morph components automatically include
+the engine's `solid_objects/component_refresh` JavaScript module; the host does
+not need a Stimulus controller or custom stream action. The default Rails
+Propshaft and Sprockets setups discover namespaced engine assets automatically.
+An application created with `--skip-asset-pipeline` should use replace refreshes
+unless it explicitly serves that module.
```ruby
# config/routes.rb
diff --git a/app/assets/javascripts/solid_objects/component_refresh.js b/app/assets/javascripts/solid_objects/component_refresh.js
new file mode 100644
index 0000000..cfce0d8
--- /dev/null
+++ b/app/assets/javascripts/solid_objects/component_refresh.js
@@ -0,0 +1,124 @@
+const activeRefreshes = new Map()
+
+class SolidObjectsRefreshElement extends HTMLElement {
+ connectedCallback() {
+ if (this.dataset.started === "true") return
+
+ this.dataset.started = "true"
+ this.refresh()
+ }
+
+ disconnectedCallback() {
+ this.refreshController?.abort()
+ }
+
+ async refresh() {
+ const targetName = this.dataset.target
+ const source = this.dataset.source
+ if (!targetName || !document.getElementById(targetName) || !source) {
+ return this.remove()
+ }
+
+ const previousRefresh = activeRefreshes.get(targetName)
+ previousRefresh?.abort()
+
+ const refresh = new AbortController()
+ this.refreshController = refresh
+ activeRefreshes.set(targetName, refresh)
+
+ try {
+ const sourceUrl = this.sourceUrl(source)
+ const response = await fetch(sourceUrl, {
+ credentials: "same-origin",
+ headers: {
+ Accept: "text/html",
+ "Turbo-Frame": targetName
+ },
+ redirect: "error",
+ signal: refresh.signal
+ })
+ if (!response.ok) {
+ this.dispatchRefreshError(`http_${response.status}`)
+ return
+ }
+
+ const responseDocument = new DOMParser().parseFromString(
+ await response.text(),
+ "text/html"
+ )
+ const replacement = responseDocument.getElementById(targetName)
+ if (!replacement || replacement.tagName !== "TURBO-FRAME") {
+ this.dispatchRefreshError("missing_frame")
+ return
+ }
+ const currentTarget = document.getElementById(targetName)
+ if (!currentTarget || !newerRevision(replacement, currentTarget)) return
+
+ renderMorph(targetName, replacement)
+ } catch (error) {
+ if (error.name !== "AbortError") {
+ this.dispatchRefreshError("request_failed")
+ }
+ } finally {
+ if (activeRefreshes.get(targetName) === refresh) {
+ activeRefreshes.delete(targetName)
+ }
+ this.remove()
+ }
+ }
+
+ sourceUrl(source) {
+ const sourceUrl = new URL(source, window.location.href)
+ if (sourceUrl.origin === window.location.origin) return sourceUrl
+
+ throw new Error("cross_origin_source")
+ }
+
+ dispatchRefreshError(reason) {
+ this.dispatchEvent(
+ new CustomEvent("solid-objects:component-refresh-error", {
+ bubbles: true,
+ detail: { reason }
+ })
+ )
+ }
+}
+
+function newerRevision(candidate, current) {
+ const candidateRevision = revisionFor(candidate)
+ const currentRevision = revisionFor(current)
+ if (!candidateRevision || !currentRevision) return false
+
+ return candidateRevision[0] > currentRevision[0] ||
+ (candidateRevision[0] === currentRevision[0] &&
+ candidateRevision[1] > currentRevision[1])
+}
+
+function revisionFor(element) {
+ const revision = element.dataset.solidObjectsRevision
+ if (!revision) return
+
+ const values = revision.split(":").map(Number)
+ if (
+ values.length !== 2 ||
+ values.some((value) => !Number.isSafeInteger(value) || value < 0)
+ ) return
+
+ return values
+}
+
+function renderMorph(targetName, replacement) {
+ const stream = document.createElement("turbo-stream")
+ stream.setAttribute("action", "replace")
+ stream.setAttribute("method", "morph")
+ stream.setAttribute("target", targetName)
+
+ const template = document.createElement("template")
+ template.content.append(document.importNode(replacement, true))
+ stream.append(template)
+ document.documentElement.append(stream)
+}
+
+if (!customElements.get("solid-objects-refresh")) {
+ customElements.define("solid-objects-refresh", SolidObjectsRefreshElement)
+}
diff --git a/app/controllers/solid_objects/components_controller.rb b/app/controllers/solid_objects/components_controller.rb
index eb61e84..d593051 100644
--- a/app/controllers/solid_objects/components_controller.rb
+++ b/app/controllers/solid_objects/components_controller.rb
@@ -21,8 +21,7 @@ def show
.call(controller: self)
rendered = ComponentRenderer.new(
snapshot:,
- component_name: registration.component_name,
- dependencies: registration.dependencies,
+ registration:,
view_context: component_view_context,
authorization_context:
).call
@@ -67,12 +66,8 @@ def component_view_context
# @rbs (ComponentRegistration, ActorSnapshot, untyped) -> String
def component_frame(registration, snapshot, rendered)
- target = DomIdentity.component(
- registration.reference,
- registration.component_name
- )
revision = "#{snapshot.instance_id}:#{snapshot.revision}"
- %(#{rendered}).html_safe
+ %(#{rendered}).html_safe
end
end
end
diff --git a/app/helpers/solid_objects/actor_helper.rb b/app/helpers/solid_objects/actor_helper.rb
index 4d7c2db..11e3dff 100644
--- a/app/helpers/solid_objects/actor_helper.rb
+++ b/app/helpers/solid_objects/actor_helper.rb
@@ -23,10 +23,17 @@ def solid_object(reference, authorization_context: self, &block)
channel: "SolidObjects::ActorChannel",
data: subscription_data
)
+ refresh_client = if actor.morph_components?
+ javascript_include_tag(
+ "solid_objects/component_refresh",
+ type: "module",
+ data: { turbo_track: "reload" }
+ )
+ end
content_tag(
:div,
- safe_join([ subscription, content ]),
+ safe_join([ refresh_client, subscription, content ].compact),
id: DomIdentity.scope(reference)
)
end
diff --git a/docs/adr/0009-realtime-updates.md b/docs/adr/0009-realtime-updates.md
index d46fa9a..612cc85 100644
--- a/docs/adr/0009-realtime-updates.md
+++ b/docs/adr/0009-realtime-updates.md
@@ -11,11 +11,28 @@ Action Cable broadcasts are online-only. A transaction can roll back, a broadcas
The executor evaluates declared observables before and after a successful message. Changed values create broadcast outbox records inside the message commit. A broadcast worker delivers Turbo Stream replacements after commit.
-One `solid_object` block creates one signed Action Cable subscription and contains stable targets for multiple observables and components. Subscription authorization runs after token verification and before streaming. Reconnect refresh reads current actor state; the broadcast stream is an optimization, not state.
+One `solid_object` block creates one signed Action Cable subscription and
+contains stable targets for multiple observables and components. Component
+names may repeat behind signed string or integer keys. Small JSON locals,
+dependencies, refresh strategy, and initial revision are signed into each
+component registration.
+
+Subscription authorization runs after token verification and before streaming.
+Every initial or request-time component render separately authorizes the
+component name and dependencies with its signed key and locals. Personalized
+HTML is never stored or broadcast.
+
+Replace refreshes use Turbo Frames. Optional morph refreshes use a gem-owned
+browser element to fetch the same authorized endpoint, abort superseded
+requests, reject stale revisions, and apply Turbo's scoped morph operation.
+Reconnect refresh reads current actor state; the broadcast stream is an
+optimization, not state.
## Consequences
- Disconnected clients may miss individual broadcasts but can converge by refresh.
- Broadcast delivery is at least once and replacements must be idempotent.
- Actor IDs and signed stream names are identifiers, not authorization.
+- Component keys and locals are visible integrity-protected inputs, not
+ secrets or capabilities.
- Realtime support is optional and loaded only when Action Cable and Turbo are present.
diff --git a/docs/architecture.md b/docs/architecture.md
index 24df7aa..abdd422 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -473,13 +473,20 @@ emits:
- Stable child target IDs for values and components
- A signed actor token used by the channel subscription
- Signed component registrations containing a conventional component name,
- explicit observable dependencies, the initial actor incarnation/revision,
- and a same-origin engine refresh path
+ optional string or integer key, JSON locals, explicit observable
+ dependencies, refresh strategy, the initial actor incarnation/revision, and
+ a same-origin engine refresh path
```erb
-<%= solid_object current_cart do |cart| %>
- Cart items: <%= cart.items_count %>
- <%= cart.component :summary, observes: %i[items checkout_status] %>
+<%= solid_object @room do |room| %>
+ Present: <%= room.presence %>
+ <% @players.each do |player| %>
+ <%= room.component :player,
+ key: player.id,
+ observes: :players,
+ locals: { player_id: player.id },
+ refresh: :morph %>
+ <% end %>
<% end %>
```
@@ -487,9 +494,11 @@ The signed token proves integrity, not authorization. `ActorChannel#subscribed`
Scalar observable calls remain direct escaped Turbo replacements. A reactive
component resolves only `actors//_`, receives its
-declared observables as frozen Ruby values, and cannot read raw state or a
-dependency it did not declare. A static initial-only component can still use a
-server-selected explicit partial; a reactive component cannot.
+declared observables and signed locals as frozen Ruby values, and cannot read
+raw state or a dependency it did not declare. Repeated component names use a
+keyed digest in the DOM identity, subscription revision map, and duplicate
+check. A static initial-only component can still use a server-selected
+explicit partial; a reactive component cannot.
Broadcast replacements happen after the actor transaction commits because only
a committed broadcast outbox row can be delivered. Multiple scalar values and
@@ -502,24 +511,29 @@ dependencies do not send their values to the browser, and the stream never
contains personalized component HTML. For each subscription, `ActorChannel`
matches the changed observable to registered component dependencies. It
coalesces multiple dependencies at the same message sequence and drops older
-revision pairs. A component invalidation replaces its stable target with a
-Turbo Frame whose source is the signed engine endpoint.
+revision pairs independently for every component name and key pair. A default
+component invalidation replaces its stable target with a Turbo Frame whose
+source is the signed engine endpoint. A morph invalidation appends a temporary
+gem-owned refresh element carrying the same signed URL.
The browser then makes an ordinary cookie-bearing HTTP request. The engine
controller derives a request-specific context through
`component_authorization_context`, calls `authorize_query` for the component
-name and every declared dependency, renders the host partial from a new
-committed snapshot, and returns `private, no-store` HTML. Subscribers to the
-same actor can therefore receive different HTML without sharing it through
-Cable or the database.
+name and every declared dependency with the signed key and locals as
+authorization arguments, renders the host partial from a new committed
+snapshot, and returns `private, no-store` HTML. Subscribers to the same actor
+can therefore receive different HTML without sharing it through Cable or the
+database.
Each channel subscription transmits current scalar replacements and compares
each component's signed initial revision against the latest committed
`(instance_id, state_revision)` pair, including after reconnect. Missing a
broadcast therefore creates temporary staleness, not permanent divergence.
The instance primary key distinguishes destroy-and-recreate incarnations.
-Replacing the full frame on each newer invalidation detaches an older in-flight
-frame, preventing its slower response from replacing the current generation.
+Replace refreshes detach an older in-flight frame. Morph refreshes abort a
+superseded fetch for the same target, re-read the current DOM target after the
+response arrives, compare monotonic revision pairs, and apply authorized HTML
+through Turbo's scoped morph operation only when it is newer.
## Authorization
@@ -540,8 +554,10 @@ No controller, channel, or administrative command treats an actor ID, message ID
Initial component rendering, Cable subscription, and request-time component
refresh deliberately use different authorization contexts. Signed component
-tokens constrain actor identity, component convention, dependencies, revision,
-and same-origin refresh path but never grant access.
+tokens constrain actor identity, component convention, optional key and
+locals, dependencies, refresh method, revision, and same-origin refresh path
+but never grant access. Keys and locals are browser-visible integrity-protected
+inputs, not encrypted capabilities.
Actor IDs are bounded UTF-8 strings and never become constant names, SQL identifiers, file paths, or raw stream names.
diff --git a/docs/authorization.md b/docs/authorization.md
index 4ac0f5a..625eaf5 100644
--- a/docs/authorization.md
+++ b/docs/authorization.md
@@ -57,6 +57,30 @@ The stream token also signs the scalar observable targets rendered into that
specific scope. Component-only dependencies send invalidation metadata but not
their state value to the browser.
+Keyed components sign their `component_key` and declared JSON-compatible
+locals into the component token. Initial rendering and every refresh pass those
+values to `authorize_query` as `arguments`; unkeyed components without locals
+retain an empty arguments hash. This lets a policy authorize a projection such
+as one seat or player:
+
+```ruby
+configuration.authorize_query = lambda do |actor_type:, actor_id:, message_name:, arguments:, authorization_context:|
+ user = authorization_context
+ player_id = arguments["player_id"]
+
+ actor_type == "PlaymatRoom" &&
+ user.present? &&
+ user.can_view_room?(actor_id) &&
+ (player_id.nil? || user.can_view_player?(player_id))
+end
+```
+
+The values are signed but not encrypted. They are present in server-rendered
+HTML and the Cable subscription identifier, so they must not contain secrets
+or sensitive state. A valid signature proves that the server issued the
+registration; it does not prove the current user may still read it. Always
+reauthorize against the current request context.
+
## A tenant-aware policy
Pass the authenticated user as the call context:
diff --git a/docs/correctness.md b/docs/correctness.md
index aa8fbb9..39f4571 100644
--- a/docs/correctness.md
+++ b/docs/correctness.md
@@ -137,10 +137,18 @@ its name and revision over Cable, not its serialized value.
Cable compares `(instance_id, state_revision)` pairs, coalesces dependencies
changed by the same turn, and ignores an older pair after a newer one. A new
-invalidation replaces the whole Turbo Frame generation. A response owned by
-the detached older frame cannot overwrite the current frame. Reconnect
-compares the component's signed initial pair with the current instance row and
-requests the latest committed snapshot when stale.
+invalidation advances each keyed component registration independently. Replace
+refreshes replace the whole Turbo Frame generation, so a response owned by the
+detached older frame cannot overwrite the current frame. Morph refreshes abort
+a superseded request for the same target and compare the response revision
+with the current DOM revision immediately before applying Turbo's scoped
+morph. Reconnect compares every component's signed initial pair with the
+current instance row and requests the latest committed snapshot when stale.
+
+Component keys, JSON locals, dependencies, and refresh strategy are covered by
+the signed registration. Keys and locals are visible to the browser and are
+passed back to `authorize_query` on every render; integrity never substitutes
+for request-specific authorization.
## Synchronous invocation
diff --git a/docs/realtime.md b/docs/realtime.md
index 0440145..29b9dac 100644
--- a/docs/realtime.md
+++ b/docs/realtime.md
@@ -28,11 +28,37 @@ never influence partial resolution. The older
`actor.component(:summary, partial: "server/chosen/path")` form remains
available for initial-only static rendering.
-The partial receives exactly two component locals:
+The partial receives these built-in component locals:
- `actor`, which exposes the declared observables as deeply frozen ordinary
Ruby values plus `actor_id` and `reference`; and
- `authorization_context`, the context for this initial render or refresh.
+- `component_key`, the signed string or integer key, or `nil` for an unkeyed
+ component.
+
+Applications can declare additional JSON-compatible locals. They are
+normalized, signed into the component token, deeply frozen, and supplied on
+both the initial render and every refresh:
+
+```erb
+<% @players.each do |player| %>
+ <%= actor.component :player,
+ key: player.id,
+ observes: %i[players life_totals],
+ locals: { player_id: player.id } %>
+<% end %>
+```
+
+This resolves every instance to the same `_player.html.erb` partial while
+giving each one a distinct opaque DOM target. The `(component name, key)` pair
+must be unique within one `solid_object` scope. An unkeyed component retains
+the existing target and uniqueness behavior.
+
+Local names must be valid Ruby local identifiers. `actor`,
+`authorization_context`, and `component_key` are reserved. Local values must
+use the same safe JSON value set as actor messages. Tokens are limited to
+16 KiB and one subscription accepts at most 50 components, so locals should be
+small identifiers or rendering options rather than copied actor state.
`actor.state` is unavailable in reactive components. Reading an observable not
listed in `observes:` raises `UnknownComponentDependency`. This keeps
@@ -51,6 +77,39 @@ Arrays, hashes, loops, conditionals, nested markup, and host helper output are
normal ERB. Escaping remains Action View's responsibility; Solid Objects never
marks actor strings as HTML safe.
+## Replace and morph refreshes
+
+Reactive components use `refresh: :replace` by default. The existing path
+replaces the target with a Turbo Frame whose signed URL performs the authorized
+request-time render.
+
+Use `refresh: :morph` when preserving unchanged DOM nodes matters:
+
+```erb
+<%= actor.component :battlefield,
+ key: player.id,
+ observes: %i[battlefields zone_counts],
+ locals: { player_id: player.id },
+ refresh: :morph %>
+```
+
+Morph invalidations append a short-lived gem-owned browser element to the
+actor scope. It fetches the same signed component endpoint with normal
+same-origin cookies, aborts an older request for the same keyed target, and
+converts the authorized response into Turbo's scoped
+`replace method="morph"`. Before applying it, the browser compares the
+response's `(instance_id, state_revision)` with the current target. An older
+response cannot overwrite newer HTML.
+
+The engine exposes the `solid_objects/component_refresh` module through the
+host asset pipeline and `solid_object` includes it only when the scope contains
+a morph component. No host Stimulus controller, custom channel, custom stream
+action, or polling loop is required. Default Propshaft and Sprockets
+applications discover the namespaced engine asset. Applications created with
+`--skip-asset-pipeline` should keep the default replace strategy unless they
+explicitly serve the module. Turbo's normal morph rules still apply; use
+`data-turbo-permanent` for elements that must never be changed.
+
## Authorization
The HTML contains a signed actor identity token. Signing prevents modification;
@@ -61,9 +120,10 @@ approval.
Initial scalar and component reads call `authorize_query` with the context
passed to `solid_object`. The refresh controller resolves a new request context
through `component_authorization_context`, then calls `authorize_query` again
-for the component name and every declared dependency. The default resolver
-supplies the engine controller; applications commonly resolve it to
-`Current.user`:
+for the component name and every declared dependency. Keyed registrations pass
+their `component_key` plus all declared locals as `arguments` at both
+boundaries. The default resolver supplies the engine controller; applications
+commonly resolve it to `Current.user`:
```ruby
configuration.component_authorization_context = ->(controller:) { Current.user }
@@ -77,8 +137,10 @@ The three contexts are intentionally different:
| Action Cable subscription | The authenticated Cable connection |
| Component refresh | Value returned by `component_authorization_context` for the engine controller request |
-Do not substitute a signed token for any of them. Never authorize solely from
-actor ID, token possession, stream name, component name, or DOM ID.
+Do not substitute a signed token for any of them. Keys and locals are visible
+to the browser and signed for integrity, not encrypted or authorized. Never
+authorize solely from actor ID, token possession, stream name, component name,
+component key, locals, or DOM ID.
## Broadcast durability
@@ -96,10 +158,11 @@ authorized browser requests affected components through the engine endpoint
with its normal cookies. Responses are `private, no-store`.
Several changed dependencies from one message sequence produce one logical
-refresh for a component. An unrelated observable does not refresh it. If a
-newer invalidation arrives while a Turbo Frame request is in flight, the new
-frame replaces the old frame element; the detached older response has no
-current target.
+refresh for each keyed component registration. An unrelated observable does
+not refresh it. If a newer invalidation arrives while a replace request is in
+flight, the new frame replaces the old frame element; the detached older
+response has no current target. Morph requests are coalesced per target with an
+`AbortController` and perform a final client-side revision comparison.
If Cable delivery is lost, reconnecting `ActorChannel` transmits replacements
from current actor state. It compares the signed component revision with the
@@ -111,8 +174,8 @@ truth.
The component endpoint rejects a requested revision newer than the committed
snapshot. This is a final server-side guard; browser safety primarily comes
-from monotonic channel filtering and replacing the entire Turbo Frame
-generation.
+from monotonic channel filtering plus replace-frame detachment or morph
+response revision fencing.
## Cost model
@@ -120,7 +183,9 @@ The durable row cost is unchanged: one broadcast row per changed observable,
containing its JSON value and the message/instance references needed to derive
invalidation metadata. No rendered document is stored. Each affected component
adds one authorized GET and one partial render per non-coalesced state
-revision. Scalar observables remain the cheaper path for one text value.
+revision. A repeated keyed component adds one GET and render per key. Signed
+locals increase page and Cable subscription bytes but do not create durable
+rows. Scalar observables remain the cheaper path for one text value.
## Deployment
diff --git a/docs/roadmap.md b/docs/roadmap.md
index da8cbf1..c2fd5bb 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -15,8 +15,8 @@
- Transactional effects with success/failure actor messages
- Actor-to-actor asynchronous outbox delivery
- One-shot and recurring reminders with `:latest` or `:all` catch-up
-- Durable observable invalidations, scalar Turbo replacement, and authorized
- request-time ERB component refresh
+- Durable observable invalidations, scalar Turbo replacement, keyed ERB
+ components, signed component locals, and authorized replace or morph refresh
- Reconciliation read APIs
- Installation doctor, authorization reference, fit guide, and legacy-state
migration cookbook
@@ -36,9 +36,10 @@
role or run periodic maintenance automatically.
- Wake-up strategy: in-process signaling plus durable polling and injection are
implemented; PostgreSQL `LISTEN/NOTIFY` and optional Redis adapters are not.
-- Realtime: scalar and dependency-driven ERB component replacement,
- personalized refresh authorization, revision fencing, coalescing, and
- reconnect convergence are implemented; Turbo append actions are not.
+- Realtime: scalar and dependency-driven keyed ERB component replacement or
+ morphing, personalized refresh authorization, revision fencing, coalescing,
+ and reconnect convergence are implemented; application-directed Turbo
+ append intents are not.
- Backpressure: mailbox/payload/state/result caps and fair yields exist;
distributed per-actor rate limits and global admission control do not.
- Administration: actor and dead-letter views plus policy hooks exist; richer
diff --git a/examples/application/app/views/chat_rooms/show.html.erb b/examples/application/app/views/chat_rooms/show.html.erb
index 362ed07..0a5911a 100644
--- a/examples/application/app/views/chat_rooms/show.html.erb
+++ b/examples/application/app/views/chat_rooms/show.html.erb
@@ -4,5 +4,7 @@
<%= room.presence %>
- <%= room.component :messages, observes: :recent_messages %>
+ <%= room.component :messages,
+ observes: :recent_messages,
+ refresh: :morph %>
<% end %>
diff --git a/lib/solid_objects/actor_view.rb b/lib/solid_objects/actor_view.rb
index d91edb2..9f5c954 100644
--- a/lib/solid_objects/actor_view.rb
+++ b/lib/solid_objects/actor_view.rb
@@ -37,10 +37,20 @@ def value(name)
)
end
- # @rbs (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?) -> untyped
- def component(name, observes: nil, partial: nil)
+ # @rbs (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?, ?key: untyped, ?locals: Hash[untyped, untyped], ?refresh: String | Symbol) -> untyped
+ def component(
+ name,
+ observes: nil,
+ partial: nil,
+ key: nil,
+ locals: {},
+ refresh: :replace
+ )
component_name = normalized_component_name(name)
- return static_component(component_name, partial:) unless observes
+ unless observes
+ validate_static_options!(key:, locals:, refresh:)
+ return static_component(component_name, partial:)
+ end
if partial
raise ArgumentError,
"reactive components resolve partials by actor and component name"
@@ -48,19 +58,21 @@ def component(name, observes: nil, partial: nil)
dependencies = normalized_dependencies(observes)
validate_dependencies!(dependencies)
- ensure_unique_component!(component_name)
refresh_path = component_path_resolver.call(view_context:)
registration = ComponentRegistration.issue(
reference:,
component_name:,
+ component_key: key,
dependencies:,
+ locals:,
+ refresh_method: refresh,
snapshot:,
refresh_path:
)
+ ensure_unique_component!(registration)
rendered = ComponentRenderer.new(
snapshot:,
- component_name:,
- dependencies:,
+ registration:,
view_context:,
authorization_context:
).call
@@ -68,9 +80,10 @@ def component(name, observes: nil, partial: nil)
view_context.content_tag(
:"turbo-frame",
rendered,
- id: DomIdentity.component(reference, component_name),
+ id: registration.dom_id,
data: {
- solid_objects_revision: "#{snapshot.instance_id}:#{snapshot.revision}"
+ solid_objects_revision: "#{snapshot.instance_id}:#{snapshot.revision}",
+ solid_objects_refresh: registration.refresh_method
}
)
end
@@ -85,6 +98,11 @@ def scalar_observable_names
observable_names.dup
end
+ # @rbs () -> bool
+ def morph_components?
+ component_registrations.any?(&:morph?)
+ end
+
# @rbs () -> State
def state
snapshot.actor.state
@@ -183,14 +201,23 @@ def validate_dependencies!(dependencies)
"unknown observable dependency #{unknown.inspect} for #{reference.actor_type}"
end
- # @rbs (String) -> void
- def ensure_unique_component!(component_name)
- return unless component_registrations.any? do |registration|
- registration.component_name == component_name
+ # @rbs (ComponentRegistration) -> void
+ def ensure_unique_component!(registration)
+ return unless component_registrations.any? do |existing_registration|
+ existing_registration.dom_id == registration.dom_id
end
raise ArgumentError,
- "component #{component_name.inspect} is already rendered in this solid_object scope"
+ "component #{registration.component_name.inspect} with key " \
+ "#{registration.component_key.inspect} is already rendered in this solid_object scope"
+ end
+
+ # @rbs (key: untyped, locals: Hash[untyped, untyped], refresh: String | Symbol) -> void
+ def validate_static_options!(key:, locals:, refresh:)
+ return if key.nil? && locals.empty? && refresh.to_s == "replace"
+
+ raise ArgumentError,
+ "key, locals, and refresh require an observable component"
end
# @rbs () -> Proc | ComponentPathResolver
diff --git a/lib/solid_objects/component_registration.rb b/lib/solid_objects/component_registration.rb
index 295e81c..020b4a9 100644
--- a/lib/solid_objects/component_registration.rb
+++ b/lib/solid_objects/component_registration.rb
@@ -6,7 +6,10 @@ module SolidObjects
class ComponentRegistration
# @rbs @reference: Reference
# @rbs @component_name: String
+ # @rbs @component_key: String | Integer?
# @rbs @dependencies: Array[String]
+ # @rbs @locals: Hash[String, untyped]
+ # @rbs @refresh_method: String
# @rbs @instance_id: Integer
# @rbs @revision: Integer
# @rbs @refresh_path: String
@@ -14,17 +17,23 @@ class ComponentRegistration
attr_reader :reference,
:component_name,
+ :component_key,
:dependencies,
+ :locals,
+ :refresh_method,
:instance_id,
:revision,
:refresh_path,
:token
- # @rbs (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String, token: String) -> void
+ # @rbs (reference: Reference, component_name: String, component_key: String | Integer?, dependencies: Array[String], locals: Hash[String, untyped], refresh_method: String, instance_id: Integer, revision: Integer, refresh_path: String, token: String) -> void
def initialize(
reference:,
component_name:,
+ component_key:,
dependencies:,
+ locals:,
+ refresh_method:,
instance_id:,
revision:,
refresh_path:,
@@ -32,7 +41,10 @@ def initialize(
)
@reference = reference
@component_name = component_name
+ @component_key = component_key
@dependencies = dependencies.freeze
+ @locals = Serialization.readonly_copy(locals)
+ @refresh_method = refresh_method
@instance_id = instance_id
@revision = revision
@refresh_path = refresh_path
@@ -40,30 +52,43 @@ def initialize(
end
class << self
- # @rbs (reference: Reference, component_name: String, dependencies: Array[String], snapshot: ActorSnapshot, refresh_path: String) -> ComponentRegistration
- def issue(reference:, component_name:, dependencies:, snapshot:, refresh_path:)
+ # @rbs (reference: Reference, component_name: String, component_key: untyped, dependencies: Array[String], locals: Hash[untyped, untyped], refresh_method: String | Symbol, snapshot: ActorSnapshot, refresh_path: String) -> ComponentRegistration
+ def issue(
+ reference:,
+ component_name:,
+ component_key:,
+ dependencies:,
+ locals:,
+ refresh_method:,
+ snapshot:,
+ refresh_path:
+ )
token = ComponentToken.generate(
reference:,
component_name:,
+ component_key:,
dependencies:,
+ locals:,
+ refresh_method:,
instance_id: snapshot.instance_id,
revision: snapshot.revision,
refresh_path:
)
- new(
- reference:,
- component_name:,
- dependencies:,
- instance_id: snapshot.instance_id,
- revision: snapshot.revision,
- refresh_path:,
- token:
- )
+ build(ComponentToken.verify(token), token:)
end
# @rbs (String) -> ComponentRegistration
def from_token(token)
payload = ComponentToken.verify(token)
+ build(payload, token:)
+ rescue UnknownActorType, UnknownComponentDependency => error
+ raise InvalidComponentToken, error.message
+ end
+
+ private
+
+ # @rbs (Hash[String, untyped], token: String) -> ComponentRegistration
+ def build(payload, token:)
reference = Reference.new(
actor_type: payload.fetch("actor_type"),
actor_id: payload.fetch("actor_id")
@@ -73,18 +98,17 @@ def from_token(token)
new(
reference:,
component_name: payload.fetch("component_name"),
+ component_key: payload["component_key"],
dependencies:,
+ locals: payload.fetch("locals"),
+ refresh_method: payload.fetch("refresh_method"),
instance_id: payload.fetch("instance_id"),
revision: payload.fetch("revision"),
refresh_path: payload.fetch("refresh_path"),
token:
)
- rescue UnknownActorType, UnknownComponentDependency => error
- raise InvalidComponentToken, error.message
end
- private
-
# @rbs (Reference, Array[String]) -> void
def validate_dependencies!(reference, dependencies)
actor_class = SolidObjects.registry.fetch(reference.actor_type)
@@ -104,6 +128,27 @@ def revision_key
[ instance_id, revision ]
end
+ # @rbs () -> String
+ def dom_id
+ DomIdentity.component(
+ reference,
+ component_name,
+ key: component_key
+ )
+ end
+
+ # @rbs () -> bool
+ def morph?
+ refresh_method == "morph"
+ end
+
+ # @rbs () -> Hash[String, untyped]
+ def authorization_arguments
+ return locals unless component_key
+
+ locals.merge("component_key" => component_key).freeze
+ end
+
# @rbs (Integer, Integer) -> String
def refresh_url(instance_id, revision)
query = URI.encode_www_form(
diff --git a/lib/solid_objects/component_renderer.rb b/lib/solid_objects/component_renderer.rb
index acd3ff4..0671889 100644
--- a/lib/solid_objects/component_renderer.rb
+++ b/lib/solid_objects/component_renderer.rb
@@ -3,46 +3,44 @@
module SolidObjects
class ComponentRenderer
# @rbs @snapshot: ActorSnapshot
- # @rbs @component_name: String
- # @rbs @dependencies: Array[String]
+ # @rbs @registration: ComponentRegistration
# @rbs @view_context: untyped
# @rbs @authorization_context: untyped
- # @rbs (snapshot: ActorSnapshot, component_name: String, dependencies: Array[String], view_context: untyped, authorization_context: untyped) -> void
+ # @rbs (snapshot: ActorSnapshot, registration: ComponentRegistration, view_context: untyped, authorization_context: untyped) -> void
def initialize(
snapshot:,
- component_name:,
- dependencies:,
+ registration:,
view_context:,
authorization_context:
)
@snapshot = snapshot
- @component_name = component_name
- @dependencies = dependencies
+ @registration = registration
@view_context = view_context
@authorization_context = authorization_context
end
# @rbs () -> untyped
def call
- [ component_name, *dependencies ].uniq.each do |authorization_name|
+ [ registration.component_name, *registration.dependencies ].uniq.each do |authorization_name|
authorize_read!(authorization_name)
end
actor = ComponentView.new(
snapshot:,
- dependencies:,
+ dependencies: registration.dependencies,
authorization_context:
)
view_context.render(
partial: default_partial,
- locals: {
+ locals: registration.locals.transform_keys(&:to_sym).merge(
actor:,
- authorization_context:
- }
+ authorization_context:,
+ component_key: registration.component_key
+ )
)
rescue ActionView::MissingTemplate
raise UnknownComponent,
- "unknown component #{component_name.inspect} for #{snapshot.reference.actor_type}"
+ "unknown component #{registration.component_name.inspect} for #{snapshot.reference.actor_type}"
rescue ActionView::Template::Error => error
raise error.cause if error.cause.is_a?(SolidObjects::Error)
@@ -52,8 +50,7 @@ def call
private
attr_reader :snapshot,
- :component_name,
- :dependencies,
+ :registration,
:view_context,
:authorization_context
@@ -63,7 +60,7 @@ def authorize_read!(observable_name)
actor_type: snapshot.reference.actor_type,
actor_id: snapshot.reference.actor_id,
message_name: observable_name,
- arguments: {},
+ arguments: registration.authorization_arguments,
authorization_context:
)
return if authorized
@@ -76,7 +73,7 @@ def default_partial
actor_name = snapshot.actor_class.name
raise InvalidActor, "anonymous actors cannot render reactive components" unless actor_name
- "actors/#{actor_name.underscore}/#{component_name}"
+ "actors/#{actor_name.underscore}/#{registration.component_name}"
end
end
end
diff --git a/lib/solid_objects/component_subscriptions.rb b/lib/solid_objects/component_subscriptions.rb
index 6f8956e..db028d2 100644
--- a/lib/solid_objects/component_subscriptions.rb
+++ b/lib/solid_objects/component_subscriptions.rb
@@ -28,7 +28,7 @@ def self.parse(serialized, reference:)
validate_identity!(registration, reference)
end
end
- if registrations.map(&:component_name).uniq.length != registrations.length
+ if registrations.map(&:dom_id).uniq.length != registrations.length
raise InvalidComponentToken, "duplicate actor component registration"
end
@@ -39,7 +39,7 @@ def self.parse(serialized, reference:)
def initialize(registrations)
@registrations = registrations
@revisions = registrations.to_h do |registration|
- [ registration.component_name, registration.revision_key ]
+ [ registration.dom_id, registration.revision_key ]
end
end
@@ -51,7 +51,7 @@ def refreshes_for(invalidation)
registrations.filter_map do |registration|
next unless registration.dependencies.include?(observable_name)
next unless newer_revision?(
- registration.component_name,
+ registration.dom_id,
instance_id,
revision
)
@@ -64,7 +64,7 @@ def refreshes_for(invalidation)
def reconnect_refreshes(snapshot)
registrations.filter_map do |registration|
next unless newer_revision?(
- registration.component_name,
+ registration.dom_id,
snapshot.instance_id,
snapshot.revision
)
@@ -92,7 +92,7 @@ def validate_identity!(registration, reference)
# @rbs (ComponentRegistration, Integer, Integer) -> String
def refresh(registration, instance_id, revision)
- revisions[registration.component_name] = [ instance_id, revision ]
+ revisions[registration.dom_id] = [ instance_id, revision ]
TurboStreamRenderer.component_refresh(
registration,
instance_id,
@@ -101,8 +101,8 @@ def refresh(registration, instance_id, revision)
end
# @rbs (String, Integer, Integer) -> bool
- def newer_revision?(component_name, instance_id, revision)
- current = revisions.fetch(component_name)
+ def newer_revision?(dom_id, instance_id, revision)
+ current = revisions.fetch(dom_id)
(current <=> [ instance_id, revision ]) == -1
end
end
diff --git a/lib/solid_objects/component_token.rb b/lib/solid_objects/component_token.rb
index 9e635a4..1bdd6a8 100644
--- a/lib/solid_objects/component_token.rb
+++ b/lib/solid_objects/component_token.rb
@@ -7,23 +7,38 @@ module ComponentToken
PURPOSE = "solid_objects.actor_component"
MAXIMUM_TOKEN_BYTES = 16_384
MAXIMUM_DEPENDENCIES = 50
+ MAXIMUM_LOCALS = 50
+ MAXIMUM_COMPONENT_KEY_BYTES = 512
+ REFRESH_METHODS = %w[replace morph].freeze
+ RESERVED_LOCALS = %w[actor authorization_context component_key].freeze
+ RUBY_KEYWORDS = %w[
+ alias and begin break case class def defined do else elsif end ensure
+ false for if in module next nil not or redo rescue retry return self
+ super then true undef unless until when while yield
+ ].freeze
module_function
- # @rbs (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String) -> String
+ # @rbs (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String, ?component_key: untyped, ?locals: Hash[untyped, untyped], ?refresh_method: String | Symbol) -> String
def generate(
reference:,
component_name:,
dependencies:,
instance_id:,
revision:,
- refresh_path:
+ refresh_path:,
+ component_key: nil,
+ locals: {},
+ refresh_method: "replace"
)
payload = {
"actor_type" => reference.actor_type,
"actor_id" => reference.actor_id,
"component_name" => component_name,
+ "component_key" => Serialization.dump(component_key),
"dependencies" => dependencies,
+ "locals" => Serialization.dump(locals),
+ "refresh_method" => refresh_method.to_s,
"instance_id" => instance_id,
"revision" => revision,
"refresh_path" => refresh_path
@@ -41,6 +56,7 @@ def verify(token)
end
payload = verifier.verified(token, purpose: PURPOSE)
+ apply_defaults!(payload)
validate_payload!(payload)
payload
rescue ActiveSupport::MessageVerifier::InvalidSignature
@@ -54,6 +70,8 @@ def validate_payload!(payload)
payload["actor_id"].is_a?(String) &&
payload["component_name"].is_a?(String) &&
payload["dependencies"].is_a?(Array) &&
+ payload["locals"].is_a?(Hash) &&
+ payload["refresh_method"].is_a?(String) &&
payload["instance_id"].is_a?(Integer) &&
payload["revision"].is_a?(Integer) &&
payload["refresh_path"].is_a?(String)
@@ -61,7 +79,10 @@ def validate_payload!(payload)
end
validate_component_name!(payload.fetch("component_name"))
+ validate_component_key!(payload["component_key"])
validate_dependencies!(payload.fetch("dependencies"))
+ validate_locals!(payload.fetch("locals"))
+ validate_refresh_method!(payload.fetch("refresh_method"))
validate_revision!(
payload.fetch("instance_id"),
payload.fetch("revision")
@@ -71,6 +92,16 @@ def validate_payload!(payload)
end
private_class_method :validate_payload!
+ # @rbs (Hash[String, untyped]?) -> void
+ def apply_defaults!(payload)
+ return unless payload.is_a?(Hash)
+
+ payload["component_key"] = nil unless payload.key?("component_key")
+ payload["locals"] = {} unless payload.key?("locals")
+ payload["refresh_method"] = "replace" unless payload.key?("refresh_method")
+ end
+ private_class_method :apply_defaults!
+
# @rbs (String) -> void
def validate_component_name!(component_name)
return if component_name.match?(/\A[a-zA-Z0-9_]+\z/)
@@ -79,6 +110,20 @@ def validate_component_name!(component_name)
end
private_class_method :validate_component_name!
+ # @rbs (untyped) -> void
+ def validate_component_key!(component_key)
+ return if component_key.nil?
+ return if component_key.is_a?(Integer)
+ if component_key.is_a?(String) &&
+ component_key.bytesize.positive? &&
+ component_key.bytesize <= MAXIMUM_COMPONENT_KEY_BYTES
+ return
+ end
+
+ raise InvalidComponentToken, "invalid actor component key"
+ end
+ private_class_method :validate_component_key!
+
# @rbs (Array[untyped]) -> void
def validate_dependencies!(dependencies)
valid = dependencies.any? &&
@@ -91,6 +136,29 @@ def validate_dependencies!(dependencies)
end
private_class_method :validate_dependencies!
+ # @rbs (Hash[untyped, untyped]) -> void
+ def validate_locals!(locals)
+ valid = locals.length <= MAXIMUM_LOCALS &&
+ locals.keys.all? do |name|
+ name.is_a?(String) &&
+ name.match?(/\A[a-z_][a-zA-Z0-9_]*\z/) &&
+ !RESERVED_LOCALS.include?(name) &&
+ !RUBY_KEYWORDS.include?(name)
+ end
+ return if valid
+
+ raise InvalidComponentToken, "invalid actor component locals"
+ end
+ private_class_method :validate_locals!
+
+ # @rbs (String) -> void
+ def validate_refresh_method!(refresh_method)
+ return if REFRESH_METHODS.include?(refresh_method)
+
+ raise InvalidComponentToken, "invalid actor component refresh method"
+ end
+ private_class_method :validate_refresh_method!
+
# @rbs (Integer, Integer) -> void
def validate_revision!(instance_id, revision)
return unless instance_id.negative? || revision.negative?
diff --git a/lib/solid_objects/dom_identity.rb b/lib/solid_objects/dom_identity.rb
index 934af91..ef35859 100644
--- a/lib/solid_objects/dom_identity.rb
+++ b/lib/solid_objects/dom_identity.rb
@@ -16,9 +16,12 @@ def observable(reference, name)
"#{scope(reference)}_observable_#{normalized_name(name)}"
end
- # @rbs (Reference, Symbol | String) -> String
- def component(reference, name)
- "#{scope(reference)}_component_#{normalized_name(name)}"
+ # @rbs (Reference, Symbol | String, ?key: String | Integer?) -> String
+ def component(reference, name, key: nil)
+ identity = "#{scope(reference)}_component_#{normalized_name(name)}"
+ return identity unless key
+
+ "#{identity}_#{component_key_digest(key)}"
end
# @rbs (Reference) -> String
@@ -34,5 +37,11 @@ def normalized_name(name)
name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
end
private_class_method :normalized_name
+
+ # @rbs (String | Integer) -> String
+ def component_key_digest(key)
+ Digest::SHA256.hexdigest(JSON.generate(key)).first(24)
+ end
+ private_class_method :component_key_digest
end
end
diff --git a/lib/solid_objects/engine.rb b/lib/solid_objects/engine.rb
index 4fb9986..f0efca8 100644
--- a/lib/solid_objects/engine.rb
+++ b/lib/solid_objects/engine.rb
@@ -26,6 +26,13 @@ class Engine < ::Rails::Engine
end
end
+ initializer "solid_objects.assets" do |application|
+ next unless application.config.respond_to?(:assets)
+ next unless application.config.assets.respond_to?(:precompile)
+
+ application.config.assets.precompile << "solid_objects/component_refresh.js"
+ end
+
rake_tasks do
tasks_path = File.expand_path("../tasks", __dir__)
Dir[File.join(tasks_path, "**/*.rake")].sort.each { |task| load task }
diff --git a/lib/solid_objects/turbo_stream_renderer.rb b/lib/solid_objects/turbo_stream_renderer.rb
index 54b4b77..a03463a 100644
--- a/lib/solid_objects/turbo_stream_renderer.rb
+++ b/lib/solid_objects/turbo_stream_renderer.rb
@@ -40,15 +40,14 @@ def observable_value(reference, name, value)
# @rbs (ComponentRegistration, Integer, Integer) -> String
def component_refresh(registration, instance_id, revision)
- target = DomIdentity.component(
- registration.reference,
- registration.component_name
- )
+ return morph_component_refresh(registration, instance_id, revision) if registration.morph?
+
+ target = registration.dom_id
source = ERB::Util.html_escape(
registration.refresh_url(instance_id, revision)
)
revision_value = "#{instance_id}:#{revision}"
- %()
+ %()
end
# @rbs (String) -> Hash[String, untyped]?
@@ -75,5 +74,15 @@ def display_value(value)
JSON.generate(value)
end
private_class_method :display_value
+
+ # @rbs (ComponentRegistration, Integer, Integer) -> String
+ def morph_component_refresh(registration, instance_id, revision)
+ source = ERB::Util.html_escape(
+ registration.refresh_url(instance_id, revision)
+ )
+ scope = DomIdentity.scope(registration.reference)
+ %()
+ end
+ private_class_method :morph_component_refresh
end
end
diff --git a/lib/solid_objects/version.rb b/lib/solid_objects/version.rb
index 36bd9fe..2942531 100644
--- a/lib/solid_objects/version.rb
+++ b/lib/solid_objects/version.rb
@@ -1,5 +1,5 @@
# rbs_inline: enabled
module SolidObjects
- VERSION = "0.4.3"
+ VERSION = "0.5.0"
end
diff --git a/sig/generated/lib/solid_objects/actor_view.rbs b/sig/generated/lib/solid_objects/actor_view.rbs
index 254d052..ba9d869 100644
--- a/sig/generated/lib/solid_objects/actor_view.rbs
+++ b/sig/generated/lib/solid_objects/actor_view.rbs
@@ -22,8 +22,8 @@ module SolidObjects
# @rbs (Symbol | String) -> untyped
def value: (Symbol | String) -> untyped
- # @rbs (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?) -> untyped
- def component: (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?) -> untyped
+ # @rbs (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?, ?key: untyped, ?locals: Hash[untyped, untyped], ?refresh: String | Symbol) -> untyped
+ def component: (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?, ?key: untyped, ?locals: Hash[untyped, untyped], ?refresh: String | Symbol) -> untyped
# @rbs () -> Array[String]
def component_tokens: () -> Array[String]
@@ -31,6 +31,9 @@ module SolidObjects
# @rbs () -> Array[String]
def scalar_observable_names: () -> Array[String]
+ # @rbs () -> bool
+ def morph_components?: () -> bool
+
# @rbs () -> State
def state: () -> State
@@ -73,8 +76,11 @@ module SolidObjects
# @rbs (Array[String]) -> void
def validate_dependencies!: (Array[String]) -> void
- # @rbs (String) -> void
- def ensure_unique_component!: (String) -> void
+ # @rbs (ComponentRegistration) -> void
+ def ensure_unique_component!: (ComponentRegistration) -> void
+
+ # @rbs (key: untyped, locals: Hash[untyped, untyped], refresh: String | Symbol) -> void
+ def validate_static_options!: (key: untyped, locals: Hash[untyped, untyped], refresh: String | Symbol) -> void
# @rbs () -> Proc | ComponentPathResolver
def component_path_resolver: () -> Proc
diff --git a/sig/generated/lib/solid_objects/component_registration.rbs b/sig/generated/lib/solid_objects/component_registration.rbs
index 86b580b..7df0aec 100644
--- a/sig/generated/lib/solid_objects/component_registration.rbs
+++ b/sig/generated/lib/solid_objects/component_registration.rbs
@@ -2,26 +2,38 @@
module SolidObjects
class ComponentRegistration
- @reference: Reference
+ @token: String
- @component_name: String
+ @refresh_path: String
- @dependencies: Array[String]
+ @revision: Integer
@instance_id: Integer
- @revision: Integer
+ @refresh_method: String
- @refresh_path: String
+ @locals: Hash[String, untyped]
- @token: String
+ @dependencies: Array[String]
+
+ @component_key: String | Integer?
+
+ @component_name: String
+
+ @reference: Reference
attr_reader reference: untyped
attr_reader component_name: untyped
+ attr_reader component_key: untyped
+
attr_reader dependencies: untyped
+ attr_reader locals: untyped
+
+ attr_reader refresh_method: untyped
+
attr_reader instance_id: untyped
attr_reader revision: untyped
@@ -30,21 +42,33 @@ module SolidObjects
attr_reader token: untyped
- # @rbs (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String, token: String) -> void
- def initialize: (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String, token: String) -> void
+ # @rbs (reference: Reference, component_name: String, component_key: String | Integer?, dependencies: Array[String], locals: Hash[String, untyped], refresh_method: String, instance_id: Integer, revision: Integer, refresh_path: String, token: String) -> void
+ def initialize: (reference: Reference, component_name: String, component_key: String | Integer?, dependencies: Array[String], locals: Hash[String, untyped], refresh_method: String, instance_id: Integer, revision: Integer, refresh_path: String, token: String) -> void
- # @rbs (reference: Reference, component_name: String, dependencies: Array[String], snapshot: ActorSnapshot, refresh_path: String) -> ComponentRegistration
- def self.issue: (reference: Reference, component_name: String, dependencies: Array[String], snapshot: ActorSnapshot, refresh_path: String) -> ComponentRegistration
+ # @rbs (reference: Reference, component_name: String, component_key: untyped, dependencies: Array[String], locals: Hash[untyped, untyped], refresh_method: String | Symbol, snapshot: ActorSnapshot, refresh_path: String) -> ComponentRegistration
+ def self.issue: (reference: Reference, component_name: String, component_key: untyped, dependencies: Array[String], locals: Hash[untyped, untyped], refresh_method: String | Symbol, snapshot: ActorSnapshot, refresh_path: String) -> ComponentRegistration
# @rbs (String) -> ComponentRegistration
def self.from_token: (String) -> ComponentRegistration
+ # @rbs (Hash[String, untyped], token: String) -> ComponentRegistration
+ private def self.build: (Hash[String, untyped], token: String) -> ComponentRegistration
+
# @rbs (Reference, Array[String]) -> void
private def self.validate_dependencies!: (Reference, Array[String]) -> void
# @rbs () -> Array[Integer]
def revision_key: () -> Array[Integer]
+ # @rbs () -> String
+ def dom_id: () -> String
+
+ # @rbs () -> bool
+ def morph?: () -> bool
+
+ # @rbs () -> Hash[String, untyped]
+ def authorization_arguments: () -> Hash[String, untyped]
+
# @rbs (Integer, Integer) -> String
def refresh_url: (Integer, Integer) -> String
end
diff --git a/sig/generated/lib/solid_objects/component_renderer.rbs b/sig/generated/lib/solid_objects/component_renderer.rbs
index c0db81e..01dacd3 100644
--- a/sig/generated/lib/solid_objects/component_renderer.rbs
+++ b/sig/generated/lib/solid_objects/component_renderer.rbs
@@ -4,16 +4,14 @@ module SolidObjects
class ComponentRenderer
@snapshot: ActorSnapshot
- @component_name: String
-
- @dependencies: Array[String]
+ @registration: ComponentRegistration
@view_context: untyped
@authorization_context: untyped
- # @rbs (snapshot: ActorSnapshot, component_name: String, dependencies: Array[String], view_context: untyped, authorization_context: untyped) -> void
- def initialize: (snapshot: ActorSnapshot, component_name: String, dependencies: Array[String], view_context: untyped, authorization_context: untyped) -> void
+ # @rbs (snapshot: ActorSnapshot, registration: ComponentRegistration, view_context: untyped, authorization_context: untyped) -> void
+ def initialize: (snapshot: ActorSnapshot, registration: ComponentRegistration, view_context: untyped, authorization_context: untyped) -> void
# @rbs () -> untyped
def call: () -> untyped
@@ -22,9 +20,7 @@ module SolidObjects
attr_reader snapshot: untyped
- attr_reader component_name: untyped
-
- attr_reader dependencies: untyped
+ attr_reader registration: untyped
attr_reader view_context: untyped
diff --git a/sig/generated/lib/solid_objects/component_token.rbs b/sig/generated/lib/solid_objects/component_token.rbs
index 1a2cd3b..ac29aa5 100644
--- a/sig/generated/lib/solid_objects/component_token.rbs
+++ b/sig/generated/lib/solid_objects/component_token.rbs
@@ -8,8 +8,18 @@ module SolidObjects
MAXIMUM_DEPENDENCIES: ::Integer
- # @rbs (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String) -> String
- def self?.generate: (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String) -> String
+ MAXIMUM_LOCALS: ::Integer
+
+ MAXIMUM_COMPONENT_KEY_BYTES: ::Integer
+
+ REFRESH_METHODS: untyped
+
+ RESERVED_LOCALS: untyped
+
+ RUBY_KEYWORDS: untyped
+
+ # @rbs (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String, ?component_key: untyped, ?locals: Hash[untyped, untyped], ?refresh_method: String | Symbol) -> String
+ def self?.generate: (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String, ?component_key: untyped, ?locals: Hash[untyped, untyped], ?refresh_method: String | Symbol) -> String
# @rbs (String) -> Hash[String, untyped]
def self?.verify: (String) -> Hash[String, untyped]
@@ -17,12 +27,24 @@ module SolidObjects
# @rbs (Hash[String, untyped]) -> Hash[String, untyped]
def self?.validate_payload!: (Hash[String, untyped]) -> Hash[String, untyped]
+ # @rbs (Hash[String, untyped]?) -> void
+ def self?.apply_defaults!: (Hash[String, untyped]?) -> void
+
# @rbs (String) -> void
def self?.validate_component_name!: (String) -> void
+ # @rbs (untyped) -> void
+ def self?.validate_component_key!: (untyped) -> void
+
# @rbs (Array[untyped]) -> void
def self?.validate_dependencies!: (Array[untyped]) -> void
+ # @rbs (Hash[untyped, untyped]) -> void
+ def self?.validate_locals!: (Hash[untyped, untyped]) -> void
+
+ # @rbs (String) -> void
+ def self?.validate_refresh_method!: (String) -> void
+
# @rbs (Integer, Integer) -> void
def self?.validate_revision!: (Integer, Integer) -> void
diff --git a/sig/generated/lib/solid_objects/dom_identity.rbs b/sig/generated/lib/solid_objects/dom_identity.rbs
index 3ebda57..9469d93 100644
--- a/sig/generated/lib/solid_objects/dom_identity.rbs
+++ b/sig/generated/lib/solid_objects/dom_identity.rbs
@@ -8,13 +8,16 @@ module SolidObjects
# @rbs (Reference, Symbol | String) -> String
def self?.observable: (Reference, Symbol | String) -> String
- # @rbs (Reference, Symbol | String) -> String
- def self?.component: (Reference, Symbol | String) -> String
+ # @rbs (Reference, Symbol | String, ?key: String | Integer?) -> String
+ def self?.component: (Reference, Symbol | String, ?key: String | Integer?) -> String
# @rbs (Reference) -> String
def self?.identity_digest: (Reference) -> String
# @rbs (Symbol | String) -> String
def self?.normalized_name: (Symbol | String) -> String
+
+ # @rbs (String | Integer) -> String
+ def self?.component_key_digest: (String | Integer) -> String
end
end
diff --git a/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs b/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs
index 4ba3c0c..dd2bba3 100644
--- a/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs
+++ b/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs
@@ -20,5 +20,8 @@ module SolidObjects
# @rbs (untyped) -> String
def self?.display_value: (untyped) -> String
+
+ # @rbs (ComponentRegistration, Integer, Integer) -> String
+ def self?.morph_component_refresh: (ComponentRegistration, Integer, Integer) -> String
end
end
diff --git a/test/integration/actor_channel_test.rb b/test/integration/actor_channel_test.rb
index f12ccb1..aa528a5 100644
--- a/test/integration/actor_channel_test.rb
+++ b/test/integration/actor_channel_test.rb
@@ -402,6 +402,138 @@ def stream_from(_broadcasting, callback = nil, coder: nil, &block)
assert_no_streams
end
+ test "refreshes repeatable keyed components independently" do
+ reference = ChannelActor.ref("actor-1")
+ SolidObjects.configuration.authorize_subscription = ->(**) { true }
+ subscribe(
+ token: SolidObjects::StreamToken.generate(reference),
+ components: JSON.generate(
+ %w[alice bob].map do |player_id|
+ component_token(
+ reference,
+ component_name: "player",
+ component_key: player_id,
+ dependencies: %w[status],
+ locals: { player_id: },
+ revision: 0
+ )
+ end
+ )
+ )
+ reference.async(:update_all)
+ worker = SolidObjects::Worker.new
+ worker.run_until_idle
+ broadcast = SolidObjects::Broadcast.find_by!(observable_name: "status")
+
+ subscription.__send__(
+ :receive_broadcast,
+ SolidObjects::TurboStreamRenderer.observable(broadcast)
+ )
+
+ assert_equal 1, component_refreshes(reference, :player, key: "alice").length
+ assert_equal 1, component_refreshes(reference, :player, key: "bob").length
+ ensure
+ worker&.stop
+ end
+
+ test "rejects duplicate keyed component registrations" do
+ reference = ChannelActor.ref("actor-1")
+ SolidObjects.configuration.authorize_subscription = ->(**) { true }
+ token = component_token(
+ reference,
+ component_name: "player",
+ component_key: "alice",
+ dependencies: %w[status],
+ revision: 0
+ )
+
+ subscribe(
+ token: SolidObjects::StreamToken.generate(reference),
+ components: JSON.generate([ token, token ])
+ )
+
+ assert subscription.rejected?
+ assert_no_streams
+ end
+
+ test "routes invalidations by each keyed component dependency" do
+ reference = ChannelActor.ref("actor-1")
+ SolidObjects.configuration.authorize_subscription = ->(**) { true }
+ subscribe(
+ token: SolidObjects::StreamToken.generate(reference),
+ components: JSON.generate(
+ [
+ component_token(
+ reference,
+ component_name: "player",
+ component_key: "alice",
+ dependencies: %w[status],
+ revision: 0
+ ),
+ component_token(
+ reference,
+ component_name: "player",
+ component_key: "bob",
+ dependencies: %w[missing],
+ revision: 0
+ )
+ ]
+ )
+ )
+ reference.async(:update_missing)
+ worker = SolidObjects::Worker.new
+ worker.run_until_idle
+ broadcast = SolidObjects::Broadcast.find_by!(observable_name: "missing")
+
+ subscription.__send__(
+ :receive_broadcast,
+ SolidObjects::TurboStreamRenderer.observable(broadcast)
+ )
+
+ assert_empty component_refreshes(reference, :player, key: "alice")
+ assert_equal 1, component_refreshes(reference, :player, key: "bob").length
+ ensure
+ worker&.stop
+ end
+
+ test "transmits a morph refresh through the browser refresh element" do
+ reference = ChannelActor.ref("actor-1")
+ SolidObjects.configuration.authorize_subscription = ->(**) { true }
+ subscribe(
+ token: SolidObjects::StreamToken.generate(reference),
+ components: JSON.generate(
+ [
+ component_token(
+ reference,
+ component_name: "summary",
+ dependencies: %w[status],
+ refresh_method: "morph",
+ revision: 0
+ )
+ ]
+ )
+ )
+ reference.async(:update_all)
+ worker = SolidObjects::Worker.new
+ worker.run_until_idle
+ broadcast = SolidObjects::Broadcast.find_by!(observable_name: "status")
+
+ subscription.__send__(
+ :receive_broadcast,
+ SolidObjects::TurboStreamRenderer.observable(broadcast)
+ )
+
+ refresh = transmissions.find do |transmission|
+ transmission.include?(" <<~ERB,
<%= actor.items.length %> items
ERB
+ "actors/actor_helper_test/cart_actor/_player.html.erb" => <<~ERB,
+
+ <%= label %>: <%= actor.status %>
+
+ ERB
"actors/actor_helper_test/cart_actor/_leaky.html.erb" => <<~ERB
<%= actor.status %>
ERB
@@ -213,4 +219,107 @@ def close
assert_match(/status/, error.message)
end
+
+ test "renders repeatable keyed components with signed locals" do
+ reference = CartActor.ref("alice")
+
+ html = solid_object(reference) do |actor|
+ safe_join(
+ [
+ actor.component(
+ :player,
+ key: "alice",
+ observes: :status,
+ locals: { player_id: "alice", label: "You" }
+ ),
+ actor.component(
+ :player,
+ key: "bob",
+ observes: :status,
+ locals: { player_id: "bob", label: "Opponent" }
+ )
+ ]
+ )
+ end
+
+ assert_includes html, %(data-player-id="alice")
+ assert_includes html, %(data-component-key="alice")
+ assert_includes html, "You: open"
+ assert_includes html, %(data-player-id="bob")
+ assert_includes html, "Opponent: open"
+
+ registrations = component_registrations(html)
+ assert_equal %w[alice bob], registrations.map(&:component_key)
+ assert_equal(
+ [
+ { "player_id" => "alice", "label" => "You" },
+ { "player_id" => "bob", "label" => "Opponent" }
+ ],
+ registrations.map(&:locals)
+ )
+ assert_equal 2, registrations.map(&:dom_id).uniq.length
+ end
+
+ test "rejects duplicate component names and keys within one scope" do
+ error = assert_raises(ArgumentError) do
+ solid_object(CartActor.ref("alice")) do |actor|
+ safe_join(
+ [
+ actor.component(:status, key: "alice", observes: :status),
+ actor.component(:status, key: "alice", observes: :status)
+ ]
+ )
+ end
+ end
+
+ assert_match(/already rendered/, error.message)
+ end
+
+ test "rejects unsafe reactive component locals" do
+ invalid_locals = [
+ [ { actor: "shadowed" }, SolidObjects::InvalidComponentToken ],
+ [ { authorization_context: "shadowed" }, SolidObjects::InvalidComponentToken ],
+ [ { component_key: "shadowed" }, SolidObjects::InvalidComponentToken ],
+ [ { "invalid-name" => "value" }, SolidObjects::InvalidComponentToken ],
+ [ { class: "value" }, SolidObjects::InvalidComponentToken ],
+ [ { callback: Object.new }, SolidObjects::InvalidPayload ]
+ ]
+ invalid_locals.each do |locals, error_class|
+ assert_raises(error_class) do
+ solid_object(CartActor.ref("alice")) do |actor|
+ actor.component(
+ :player,
+ key: "alice",
+ observes: :status,
+ locals:
+ )
+ end
+ end
+ end
+ end
+
+ test "loads the morph refresh client only for morph components" do
+ reference = CartActor.ref("alice")
+
+ replace_html = solid_object(reference) do |actor|
+ actor.component(:status, observes: :status)
+ end
+ morph_html = solid_object(reference) do |actor|
+ actor.component(:status, observes: :status, refresh: :morph)
+ end
+
+ refute_includes replace_html, "solid_objects/component_refresh"
+ assert_includes morph_html, "solid_objects/component_refresh"
+ assert_includes morph_html, %(data-solid-objects-refresh="morph")
+ assert_equal "morph", component_registrations(morph_html).sole.refresh_method
+ end
+
+ private
+
+ def component_registrations(html)
+ serialized = CGI.unescapeHTML(html[/data-components="([^"]+)"/, 1])
+ JSON.parse(serialized).map do |token|
+ SolidObjects::ComponentRegistration.from_token(token)
+ end
+ end
end
diff --git a/test/integration/components_controller_test.rb b/test/integration/components_controller_test.rb
index f072acd..a47ba97 100644
--- a/test/integration/components_controller_test.rb
+++ b/test/integration/components_controller_test.rb
@@ -44,13 +44,18 @@ def update_room(messages:, status:)
<% end %>
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