Skip to content

Commit 8df1773

Browse files
authored
Merge pull request #9 from cardmagic/agent/batched-refreshes-and-js-tests
feat: batch component refreshes and test the browser modules
2 parents 388e59c + 829caae commit 8df1773

27 files changed

Lines changed: 1621 additions & 37 deletions

.github/workflows/ci.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ jobs:
1616
bundler-cache: true
1717
- run: bundle exec rake
1818

19+
javascript:
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
23+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
24+
with:
25+
node-version: "22"
26+
- run: npm ci
27+
- run: npm test
28+
1929
postgresql:
2030
runs-on: ubuntu-latest
2131
services:
@@ -86,6 +96,7 @@ jobs:
8696
- postgresql
8797
- mysql
8898
- static
99+
- javascript
89100
runs-on: ubuntu-latest
90101
permissions:
91102
contents: write

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@
88
/tmp
99
/.claude
1010
/.codex
11+
/node_modules

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Changelog
22

3+
## 0.7.0 - 2026-08-09
4+
5+
- Add `batch:` to reactive components. Components sharing a batch in one actor
6+
scope collapse into a single browser request per revision instead of one
7+
request per component. The new `GET /solid_objects/components/batch` endpoint
8+
returns HTML frames inside a documented JSON envelope, so Turbo morph and ERB
9+
rendering are unchanged while the contract stays machine readable. Duplicate
10+
notifications for the same batch and revision coalesce in the browser,
11+
unchanged components are never requested, and stale frames cannot overwrite a
12+
newer target. Components without `batch:` behave exactly as before.
13+
- Add a JavaScript test suite for the browser modules, run in CI with Node's
14+
test runner and jsdom.
15+
316
## 0.6.0 - 2026-08-09
417

518
- Add `broadcast_payload`, an actor DSL for sending one personalized JSON state

Gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
solid_objects (0.6.0)
4+
solid_objects (0.7.0)
55
actioncable (>= 8.0)
66
actionpack (>= 8.0)
77
actionview (>= 8.0)
@@ -373,7 +373,7 @@ CHECKSUMS
373373
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
374374
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
375375
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
376-
solid_objects (0.6.0)
376+
solid_objects (0.7.0)
377377
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
378378
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
379379
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
const pendingBatches = new Map()
2+
const activeBatches = new Map()
3+
4+
class SolidObjectsBatchRefreshElement extends HTMLElement {
5+
connectedCallback() {
6+
if (this.dataset.started === "true") return
7+
8+
this.dataset.started = "true"
9+
this.enqueue()
10+
}
11+
12+
// Several observables changing in one commit produce several notifications
13+
// for the same batch and revision. Merging them in a microtask turns those
14+
// into a single request.
15+
enqueue() {
16+
const batch = this.dataset.batch
17+
const revision = this.dataset.revision
18+
const source = this.dataset.source
19+
const scope = this.closest("[id]")?.id
20+
if (!batch || !revision || !source || !scope) return this.remove()
21+
22+
// Two actor scopes may reuse a batch name on one page. Keying by scope
23+
// keeps their requests from merging or cancelling each other.
24+
const group = `${scope}:${batch}`
25+
const key = `${group}:${revision}`
26+
const pending = pendingBatches.get(key)
27+
if (pending) {
28+
pending.sources.add(source)
29+
this.remove()
30+
return
31+
}
32+
33+
const merged = { sources: new Set([ source ]) }
34+
pendingBatches.set(key, merged)
35+
queueMicrotask(() => {
36+
pendingBatches.delete(key)
37+
requestBatch(group, batch, merged.sources)
38+
})
39+
this.remove()
40+
}
41+
}
42+
43+
async function requestBatch(group, batch, sources) {
44+
const previous = activeBatches.get(group)
45+
previous?.abort()
46+
47+
const controller = new AbortController()
48+
activeBatches.set(group, controller)
49+
50+
try {
51+
const url = mergedUrl(sources)
52+
if (!url) return
53+
54+
const response = await fetch(url, {
55+
credentials: "same-origin",
56+
headers: { Accept: "application/json" },
57+
redirect: "error",
58+
signal: controller.signal
59+
})
60+
if (!response.ok) return dispatchBatchError(batch, `http_${response.status}`)
61+
62+
const body = await response.json()
63+
if (!Array.isArray(body?.frames)) {
64+
return dispatchBatchError(batch, "invalid_response")
65+
}
66+
67+
body.frames.forEach(applyFrame)
68+
} catch (error) {
69+
if (error.name !== "AbortError") dispatchBatchError(batch, "request_failed")
70+
} finally {
71+
if (activeBatches.get(group) === controller) activeBatches.delete(group)
72+
}
73+
}
74+
75+
// Every notification for one batch and revision carries the same endpoint and
76+
// differs only by which components changed, so the union of their tokens is the
77+
// complete set to render.
78+
function mergedUrl(sources) {
79+
const urls = [ ...sources ].map((source) => new URL(source, window.location.href))
80+
const first = urls[0]
81+
if (!first || first.origin !== window.location.origin) return
82+
83+
const tokens = new Set()
84+
urls.forEach((url) => {
85+
url.searchParams.getAll("tokens[]").forEach((token) => tokens.add(token))
86+
})
87+
first.searchParams.delete("tokens[]")
88+
tokens.forEach((token) => first.searchParams.append("tokens[]", token))
89+
return first
90+
}
91+
92+
function applyFrame(frame) {
93+
const target = document.getElementById(frame?.target)
94+
if (!target || !frame.html) return
95+
if (!newerRevision(frame.revision, target.dataset.solidObjectsRevision)) return
96+
97+
const parsed = new DOMParser().parseFromString(frame.html, "text/html")
98+
const replacement = parsed.getElementById(frame.target)
99+
if (!replacement) return
100+
101+
const stream = document.createElement("turbo-stream")
102+
stream.setAttribute("action", "replace")
103+
if (frame.refresh_method === "morph") stream.setAttribute("method", "morph")
104+
stream.setAttribute("target", frame.target)
105+
106+
const template = document.createElement("template")
107+
template.content.append(document.importNode(replacement, true))
108+
stream.append(template)
109+
document.documentElement.append(stream)
110+
}
111+
112+
function newerRevision(candidate, current) {
113+
const candidateRevision = parseRevision(candidate)
114+
const currentRevision = parseRevision(current)
115+
if (!candidateRevision || !currentRevision) return false
116+
117+
return candidateRevision[0] > currentRevision[0] ||
118+
(candidateRevision[0] === currentRevision[0] &&
119+
candidateRevision[1] > currentRevision[1])
120+
}
121+
122+
function parseRevision(revision) {
123+
if (!revision) return
124+
125+
const values = String(revision).split(":").map(Number)
126+
if (
127+
values.length !== 2 ||
128+
values.some((value) => !Number.isSafeInteger(value) || value < 0)
129+
) return
130+
131+
return values
132+
}
133+
134+
function dispatchBatchError(batch, reason) {
135+
document.dispatchEvent(
136+
new CustomEvent("solid-objects:batch-refresh-error", {
137+
bubbles: true,
138+
detail: { batch, reason }
139+
})
140+
)
141+
}
142+
143+
if (!customElements.get("solid-objects-batch-refresh")) {
144+
customElements.define(
145+
"solid-objects-batch-refresh",
146+
SolidObjectsBatchRefreshElement
147+
)
148+
}

app/controllers/solid_objects/components_controller.rb

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,97 @@ module SolidObjects
66
class ComponentsController < ActionController::Base
77
protect_from_forgery with: :exception
88

9+
BATCH_LIMIT = 50
10+
911
# @rbs () -> void
1012
def show
1113
SolidObjects.instrument(:"component.refreshed") { |payload| refresh(payload) }
1214
end
1315

16+
# @rbs () -> void
17+
def batch
18+
SolidObjects.instrument(:"component.batch_refreshed") { |payload| refresh_batch(payload) }
19+
end
20+
1421
private
1522

23+
# @rbs (Hash[Symbol, untyped]) -> void
24+
def refresh_batch(payload)
25+
tokens = Array(params.require(:tokens))
26+
raise ActionController::ParameterMissing, :tokens if tokens.empty?
27+
raise ArgumentError if tokens.length > BATCH_LIMIT
28+
29+
registrations = tokens.map { |token| ComponentRegistration.from_token(token) }
30+
validate_single_batch!(registrations)
31+
requested_revision = requested_revision_key
32+
snapshot = ActorSnapshot.new(registrations.first.reference)
33+
payload.merge!(
34+
actor_type: snapshot.reference.actor_type,
35+
actor_id: snapshot.reference.actor_id,
36+
batch: registrations.first.batch,
37+
components: registrations.map(&:component_name),
38+
instance_id: snapshot.instance_id,
39+
revision: snapshot.revision
40+
)
41+
if newer_than_snapshot?(requested_revision, snapshot)
42+
payload[:outcome] = "conflict"
43+
return head :conflict
44+
end
45+
46+
authorization_context = SolidObjects
47+
.configuration
48+
.component_authorization_context
49+
.call(controller: self)
50+
frames = registrations.map do |registration|
51+
rendered = ComponentRenderer.new(
52+
snapshot:,
53+
registration:,
54+
view_context: component_view_context,
55+
authorization_context:
56+
).call
57+
{
58+
"target" => registration.dom_id,
59+
"revision" => "#{snapshot.instance_id}:#{snapshot.revision}",
60+
"refresh_method" => registration.refresh_method,
61+
"html" => component_frame(registration, snapshot, rendered)
62+
}
63+
end
64+
response.headers["Cache-Control"] = "private, no-store"
65+
payload[:outcome] = "rendered"
66+
render json: {
67+
"actor_type" => snapshot.reference.actor_type,
68+
"actor_id" => snapshot.reference.actor_id,
69+
"batch" => registrations.first.batch,
70+
"instance_id" => snapshot.instance_id,
71+
"revision" => snapshot.revision,
72+
"frames" => frames
73+
}
74+
rescue Unauthorized
75+
payload[:outcome] = "unauthorized"
76+
head :forbidden
77+
rescue UnknownComponent
78+
payload[:outcome] = "unknown_component"
79+
head :not_found
80+
rescue ActionController::ParameterMissing,
81+
ArgumentError,
82+
InvalidComponentToken
83+
payload[:outcome] = "invalid_token"
84+
head :bad_request
85+
end
86+
87+
# @rbs (Array[ComponentRegistration]) -> void
88+
def validate_single_batch!(registrations)
89+
first = registrations.first
90+
raise ArgumentError unless first.batch
91+
return if registrations.all? do |registration|
92+
registration.batch == first.batch &&
93+
registration.reference.actor_type == first.reference.actor_type &&
94+
registration.reference.actor_id == first.reference.actor_id
95+
end
96+
97+
raise ArgumentError
98+
end
99+
16100
# @rbs (Hash[Symbol, untyped]) -> void
17101
def refresh(payload)
18102
registration = ComponentRegistration.from_token(

app/helpers/solid_objects/actor_helper.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ def solid_object(reference, authorization_context: self, payloads: nil, &block)
3232
data: { turbo_track: "reload" }
3333
)
3434
end
35+
batch_client = if actor.batched_components?
36+
javascript_include_tag(
37+
"solid_objects/component_batch_refresh",
38+
type: "module",
39+
data: { turbo_track: "reload" }
40+
)
41+
end
3542
payload_client = if payload_names
3643
javascript_include_tag(
3744
"solid_objects/state_payload",
@@ -42,7 +49,9 @@ def solid_object(reference, authorization_context: self, payloads: nil, &block)
4249

4350
content_tag(
4451
:div,
45-
safe_join([ refresh_client, payload_client, subscription, content ].compact),
52+
safe_join(
53+
[ refresh_client, batch_client, payload_client, subscription, content ].compact
54+
),
4655
id: DomIdentity.scope(reference)
4756
)
4857
end

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
SolidObjects::Engine.routes.draw do
44
get :components, to: "components#show"
5+
get "components/batch", to: "components#batch"
56
resources :instances, only: %i[index show]
67
resources :dead_letters, only: %i[index] do
78
post :retry, on: :member

0 commit comments

Comments
 (0)