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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
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.4.3)
solid_objects (0.5.0)
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.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
Expand Down
61 changes: 54 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<article id="player_<%= player_id %>">
Life: <%= actor.life_totals.fetch(player_id.to_s) %>
</article>
```

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
<ul>
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
124 changes: 124 additions & 0 deletions app/assets/javascripts/solid_objects/component_refresh.js
Original file line number Diff line number Diff line change
@@ -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)
}
9 changes: 2 additions & 7 deletions app/controllers/solid_objects/components_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}"
%(<turbo-frame id="#{target}" data-solid-objects-revision="#{revision}">#{rendered}</turbo-frame>).html_safe
%(<turbo-frame id="#{registration.dom_id}" data-solid-objects-revision="#{revision}" data-solid-objects-refresh="#{registration.refresh_method}">#{rendered}</turbo-frame>).html_safe
end
end
end
9 changes: 8 additions & 1 deletion app/helpers/solid_objects/actor_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion docs/adr/0009-realtime-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading