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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ jobs:
bundler-cache: true
- run: bundle exec rake

javascript:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
- run: npm ci
- run: npm test

postgresql:
runs-on: ubuntu-latest
services:
Expand Down Expand Up @@ -86,6 +96,7 @@ jobs:
- postgresql
- mysql
- static
- javascript
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
/tmp
/.claude
/.codex
/node_modules
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 0.7.0 - 2026-08-09

- Add `batch:` to reactive components. Components sharing a batch in one actor
scope collapse into a single browser request per revision instead of one
request per component. The new `GET /solid_objects/components/batch` endpoint
returns HTML frames inside a documented JSON envelope, so Turbo morph and ERB
rendering are unchanged while the contract stays machine readable. Duplicate
notifications for the same batch and revision coalesce in the browser,
unchanged components are never requested, and stale frames cannot overwrite a
newer target. Components without `batch:` behave exactly as before.
- Add a JavaScript test suite for the browser modules, run in CI with Node's
test runner and jsdom.

## 0.6.0 - 2026-08-09

- Add `broadcast_payload`, an actor DSL for sending one personalized JSON state
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.6.0)
solid_objects (0.7.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.6.0)
solid_objects (0.7.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
148 changes: 148 additions & 0 deletions app/assets/javascripts/solid_objects/component_batch_refresh.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
const pendingBatches = new Map()
const activeBatches = new Map()

class SolidObjectsBatchRefreshElement extends HTMLElement {
connectedCallback() {
if (this.dataset.started === "true") return

this.dataset.started = "true"
this.enqueue()
}

// Several observables changing in one commit produce several notifications
// for the same batch and revision. Merging them in a microtask turns those
// into a single request.
enqueue() {
const batch = this.dataset.batch
const revision = this.dataset.revision
const source = this.dataset.source
const scope = this.closest("[id]")?.id
if (!batch || !revision || !source || !scope) return this.remove()

// Two actor scopes may reuse a batch name on one page. Keying by scope
// keeps their requests from merging or cancelling each other.
const group = `${scope}:${batch}`
const key = `${group}:${revision}`
const pending = pendingBatches.get(key)
if (pending) {
pending.sources.add(source)
this.remove()
return
}

const merged = { sources: new Set([ source ]) }
pendingBatches.set(key, merged)
queueMicrotask(() => {
pendingBatches.delete(key)
requestBatch(group, batch, merged.sources)
})
this.remove()
}
}

async function requestBatch(group, batch, sources) {
const previous = activeBatches.get(group)
previous?.abort()

const controller = new AbortController()
activeBatches.set(group, controller)

try {
const url = mergedUrl(sources)
if (!url) return

const response = await fetch(url, {
credentials: "same-origin",
headers: { Accept: "application/json" },
redirect: "error",
signal: controller.signal
})
if (!response.ok) return dispatchBatchError(batch, `http_${response.status}`)

const body = await response.json()
if (!Array.isArray(body?.frames)) {
return dispatchBatchError(batch, "invalid_response")
}

body.frames.forEach(applyFrame)
} catch (error) {
if (error.name !== "AbortError") dispatchBatchError(batch, "request_failed")
} finally {
if (activeBatches.get(group) === controller) activeBatches.delete(group)
}
}

// Every notification for one batch and revision carries the same endpoint and
// differs only by which components changed, so the union of their tokens is the
// complete set to render.
function mergedUrl(sources) {
const urls = [ ...sources ].map((source) => new URL(source, window.location.href))
const first = urls[0]
if (!first || first.origin !== window.location.origin) return

const tokens = new Set()
urls.forEach((url) => {
url.searchParams.getAll("tokens[]").forEach((token) => tokens.add(token))
})
first.searchParams.delete("tokens[]")
tokens.forEach((token) => first.searchParams.append("tokens[]", token))
return first
}

function applyFrame(frame) {
const target = document.getElementById(frame?.target)
if (!target || !frame.html) return
if (!newerRevision(frame.revision, target.dataset.solidObjectsRevision)) return

const parsed = new DOMParser().parseFromString(frame.html, "text/html")
const replacement = parsed.getElementById(frame.target)
if (!replacement) return

const stream = document.createElement("turbo-stream")
stream.setAttribute("action", "replace")
if (frame.refresh_method === "morph") stream.setAttribute("method", "morph")
stream.setAttribute("target", frame.target)

const template = document.createElement("template")
template.content.append(document.importNode(replacement, true))
stream.append(template)
document.documentElement.append(stream)
}

function newerRevision(candidate, current) {
const candidateRevision = parseRevision(candidate)
const currentRevision = parseRevision(current)
if (!candidateRevision || !currentRevision) return false

return candidateRevision[0] > currentRevision[0] ||
(candidateRevision[0] === currentRevision[0] &&
candidateRevision[1] > currentRevision[1])
}

function parseRevision(revision) {
if (!revision) return

const values = String(revision).split(":").map(Number)
if (
values.length !== 2 ||
values.some((value) => !Number.isSafeInteger(value) || value < 0)
) return

return values
}

function dispatchBatchError(batch, reason) {
document.dispatchEvent(
new CustomEvent("solid-objects:batch-refresh-error", {
bubbles: true,
detail: { batch, reason }
})
)
}

if (!customElements.get("solid-objects-batch-refresh")) {
customElements.define(
"solid-objects-batch-refresh",
SolidObjectsBatchRefreshElement
)
}
84 changes: 84 additions & 0 deletions app/controllers/solid_objects/components_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,97 @@ module SolidObjects
class ComponentsController < ActionController::Base
protect_from_forgery with: :exception

BATCH_LIMIT = 50

# @rbs () -> void
def show
SolidObjects.instrument(:"component.refreshed") { |payload| refresh(payload) }
end

# @rbs () -> void
def batch
SolidObjects.instrument(:"component.batch_refreshed") { |payload| refresh_batch(payload) }
end

private

# @rbs (Hash[Symbol, untyped]) -> void
def refresh_batch(payload)
tokens = Array(params.require(:tokens))
raise ActionController::ParameterMissing, :tokens if tokens.empty?
raise ArgumentError if tokens.length > BATCH_LIMIT

registrations = tokens.map { |token| ComponentRegistration.from_token(token) }
validate_single_batch!(registrations)
requested_revision = requested_revision_key
snapshot = ActorSnapshot.new(registrations.first.reference)
payload.merge!(
actor_type: snapshot.reference.actor_type,
actor_id: snapshot.reference.actor_id,
batch: registrations.first.batch,
components: registrations.map(&:component_name),
instance_id: snapshot.instance_id,
revision: snapshot.revision
)
if newer_than_snapshot?(requested_revision, snapshot)
payload[:outcome] = "conflict"
return head :conflict
end

authorization_context = SolidObjects
.configuration
.component_authorization_context
.call(controller: self)
frames = registrations.map do |registration|
rendered = ComponentRenderer.new(
snapshot:,
registration:,
view_context: component_view_context,
authorization_context:
).call
{
"target" => registration.dom_id,
"revision" => "#{snapshot.instance_id}:#{snapshot.revision}",
"refresh_method" => registration.refresh_method,
"html" => component_frame(registration, snapshot, rendered)
}
end
response.headers["Cache-Control"] = "private, no-store"
payload[:outcome] = "rendered"
render json: {
"actor_type" => snapshot.reference.actor_type,
"actor_id" => snapshot.reference.actor_id,
"batch" => registrations.first.batch,
"instance_id" => snapshot.instance_id,
"revision" => snapshot.revision,
"frames" => frames
}
rescue Unauthorized
payload[:outcome] = "unauthorized"
head :forbidden
rescue UnknownComponent
payload[:outcome] = "unknown_component"
head :not_found
rescue ActionController::ParameterMissing,
ArgumentError,
InvalidComponentToken
payload[:outcome] = "invalid_token"
head :bad_request
end

# @rbs (Array[ComponentRegistration]) -> void
def validate_single_batch!(registrations)
first = registrations.first
raise ArgumentError unless first.batch
return if registrations.all? do |registration|
registration.batch == first.batch &&
registration.reference.actor_type == first.reference.actor_type &&
registration.reference.actor_id == first.reference.actor_id
end

raise ArgumentError
end

# @rbs (Hash[Symbol, untyped]) -> void
def refresh(payload)
registration = ComponentRegistration.from_token(
Expand Down
11 changes: 10 additions & 1 deletion app/helpers/solid_objects/actor_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ def solid_object(reference, authorization_context: self, payloads: nil, &block)
data: { turbo_track: "reload" }
)
end
batch_client = if actor.batched_components?
javascript_include_tag(
"solid_objects/component_batch_refresh",
type: "module",
data: { turbo_track: "reload" }
)
end
payload_client = if payload_names
javascript_include_tag(
"solid_objects/state_payload",
Expand All @@ -42,7 +49,9 @@ def solid_object(reference, authorization_context: self, payloads: nil, &block)

content_tag(
:div,
safe_join([ refresh_client, payload_client, subscription, content ].compact),
safe_join(
[ refresh_client, batch_client, payload_client, subscription, content ].compact
),
id: DomIdentity.scope(reference)
)
end
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

SolidObjects::Engine.routes.draw do
get :components, to: "components#show"
get "components/batch", to: "components#batch"
resources :instances, only: %i[index show]
resources :dead_letters, only: %i[index] do
post :retry, on: :member
Expand Down
Loading
Loading