Skip to content

Commit c9fe0a0

Browse files
committed
feat: batch component refreshes and test the browser modules
Components sharing a batch collapse into one browser request per revision instead of one per component. A new endpoint returns HTML frames inside a JSON envelope, so ERB rendering and Turbo morph are unchanged while the client gets a documented contract with per-frame revisions. Duplicate notifications for one batch and revision coalesce in the browser, unchanged components are never requested, and stale frames cannot overwrite newer targets. The batch name is signed into the component token. Adds a Node test-runner and jsdom suite covering both browser modules, wired into CI.
1 parent 388e59c commit c9fe0a0

1,258 files changed

Lines changed: 220401 additions & 28 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.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@v7
23+
- uses: actions/setup-node@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

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.6.1 - 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.6.1)
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.6.1)
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: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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+
if (!batch || !revision || !source) return this.remove()
20+
21+
const key = `${batch}:${revision}`
22+
const pending = pendingBatches.get(key)
23+
if (pending) {
24+
pending.sources.add(source)
25+
this.remove()
26+
return
27+
}
28+
29+
const merged = { sources: new Set([ source ]) }
30+
pendingBatches.set(key, merged)
31+
queueMicrotask(() => {
32+
pendingBatches.delete(key)
33+
requestBatch(batch, revision, merged.sources)
34+
})
35+
this.remove()
36+
}
37+
}
38+
39+
async function requestBatch(batch, revision, sources) {
40+
const previous = activeBatches.get(batch)
41+
previous?.abort()
42+
43+
const controller = new AbortController()
44+
activeBatches.set(batch, controller)
45+
46+
try {
47+
const url = mergedUrl(sources)
48+
if (!url) return
49+
50+
const response = await fetch(url, {
51+
credentials: "same-origin",
52+
headers: { Accept: "application/json" },
53+
redirect: "error",
54+
signal: controller.signal
55+
})
56+
if (!response.ok) return dispatchBatchError(batch, `http_${response.status}`)
57+
58+
const body = await response.json()
59+
if (!Array.isArray(body?.frames)) {
60+
return dispatchBatchError(batch, "invalid_response")
61+
}
62+
63+
body.frames.forEach(applyFrame)
64+
} catch (error) {
65+
if (error.name !== "AbortError") dispatchBatchError(batch, "request_failed")
66+
} finally {
67+
if (activeBatches.get(batch) === controller) activeBatches.delete(batch)
68+
}
69+
}
70+
71+
// Every notification for one batch and revision carries the same endpoint and
72+
// differs only by which components changed, so the union of their tokens is the
73+
// complete set to render.
74+
function mergedUrl(sources) {
75+
const urls = [ ...sources ].map((source) => new URL(source, window.location.href))
76+
const first = urls[0]
77+
if (!first || first.origin !== window.location.origin) return
78+
79+
const tokens = new Set()
80+
urls.forEach((url) => {
81+
url.searchParams.getAll("tokens[]").forEach((token) => tokens.add(token))
82+
})
83+
first.searchParams.delete("tokens[]")
84+
tokens.forEach((token) => first.searchParams.append("tokens[]", token))
85+
return first
86+
}
87+
88+
function applyFrame(frame) {
89+
const target = document.getElementById(frame?.target)
90+
if (!target || !frame.html) return
91+
if (!newerRevision(frame.revision, target.dataset.solidObjectsRevision)) return
92+
93+
const parsed = new DOMParser().parseFromString(frame.html, "text/html")
94+
const replacement = parsed.getElementById(frame.target)
95+
if (!replacement) return
96+
97+
const stream = document.createElement("turbo-stream")
98+
stream.setAttribute("action", "replace")
99+
if (frame.refresh_method === "morph") stream.setAttribute("method", "morph")
100+
stream.setAttribute("target", frame.target)
101+
102+
const template = document.createElement("template")
103+
template.content.append(document.importNode(replacement, true))
104+
stream.append(template)
105+
document.documentElement.append(stream)
106+
}
107+
108+
function newerRevision(candidate, current) {
109+
const candidateRevision = parseRevision(candidate)
110+
const currentRevision = parseRevision(current)
111+
if (!candidateRevision || !currentRevision) return false
112+
113+
return candidateRevision[0] > currentRevision[0] ||
114+
(candidateRevision[0] === currentRevision[0] &&
115+
candidateRevision[1] > currentRevision[1])
116+
}
117+
118+
function parseRevision(revision) {
119+
if (!revision) return
120+
121+
const values = String(revision).split(":").map(Number)
122+
if (
123+
values.length !== 2 ||
124+
values.some((value) => !Number.isSafeInteger(value) || value < 0)
125+
) return
126+
127+
return values
128+
}
129+
130+
function dispatchBatchError(batch, reason) {
131+
document.dispatchEvent(
132+
new CustomEvent("solid-objects:batch-refresh-error", {
133+
bubbles: true,
134+
detail: { batch, reason }
135+
})
136+
)
137+
}
138+
139+
if (!customElements.get("solid-objects-batch-refresh")) {
140+
customElements.define(
141+
"solid-objects-batch-refresh",
142+
SolidObjectsBatchRefreshElement
143+
)
144+
}

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)