diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index efac674..596cc4d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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:
@@ -86,6 +96,7 @@ jobs:
- postgresql
- mysql
- static
+ - javascript
runs-on: ubuntu-latest
permissions:
contents: write
diff --git a/.gitignore b/.gitignore
index 4e09ab3..ebd9bc7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@
/tmp
/.claude
/.codex
+/node_modules
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c745ae6..578975f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/Gemfile.lock b/Gemfile.lock
index adbb972..7520c52 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -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)
@@ -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
diff --git a/app/assets/javascripts/solid_objects/component_batch_refresh.js b/app/assets/javascripts/solid_objects/component_batch_refresh.js
new file mode 100644
index 0000000..816e599
--- /dev/null
+++ b/app/assets/javascripts/solid_objects/component_batch_refresh.js
@@ -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
+ )
+}
diff --git a/app/controllers/solid_objects/components_controller.rb b/app/controllers/solid_objects/components_controller.rb
index 688953c..40514d0 100644
--- a/app/controllers/solid_objects/components_controller.rb
+++ b/app/controllers/solid_objects/components_controller.rb
@@ -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(
diff --git a/app/helpers/solid_objects/actor_helper.rb b/app/helpers/solid_objects/actor_helper.rb
index fb2358e..e23c128 100644
--- a/app/helpers/solid_objects/actor_helper.rb
+++ b/app/helpers/solid_objects/actor_helper.rb
@@ -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",
@@ -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
diff --git a/config/routes.rb b/config/routes.rb
index 42962e3..225f1ff 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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
diff --git a/docs/realtime.md b/docs/realtime.md
index a4d78b3..bfb1e1f 100644
--- a/docs/realtime.md
+++ b/docs/realtime.md
@@ -110,6 +110,80 @@ applications discover the namespaced engine asset. Applications created with
explicitly serve the module. Turbo's normal morph rules still apply; use
`data-turbo-permanent` for elements that must never be changed.
+## Batched component refreshes
+
+A component refresh costs one browser request. When one actor mutation changes
+several components, the page pays one request per component. Adding `batch:`
+groups them so a revision costs one request no matter how many components in the
+group changed:
+
+```erb
+<%= actor.component :player, key: 1,
+ observes: :player_one, batch: :playmat, refresh: :morph %>
+
+<%= actor.component :player_controls, key: 1,
+ observes: :player_one_controls, batch: :playmat, refresh: :morph %>
+
+<%= actor.component :library_search, key: 1,
+ observes: :library, batch: :playmat, refresh: :morph %>
+```
+
+Before, one mutation touching all three observables produced three requests:
+
+```
+commit -> 3 invalidations -> 3 refresh elements -> 3 GET /solid_objects/components
+```
+
+After, the three notifications coalesce in the browser into one request:
+
+```
+commit -> 3 invalidations -> 1 GET /solid_objects/components/batch -> 3 frames
+```
+
+### The batch endpoint
+
+`GET /solid_objects/components/batch` takes the signed `tokens[]` of the
+components to render plus the `instance_id` and `revision` the browser holds. It
+returns HTML frames inside a JSON envelope:
+
+```json
+{
+ "actor_type": "playmat_room",
+ "actor_id": "table-1",
+ "batch": "playmat",
+ "instance_id": 12,
+ "revision": 48,
+ "frames": [
+ {
+ "target": "solid-objects-component-...",
+ "revision": "12:48",
+ "refresh_method": "morph",
+ "html": "..."
+ }
+ ]
+}
+```
+
+**Why frames inside JSON rather than one HTML document or pure JSON state.** HTML
+alone would force the browser to pick frames out of an undocumented document.
+Pure JSON would mean a second renderer and would give up ERB and Turbo morph. A
+JSON envelope of frame descriptors keeps `ComponentRenderer` and Turbo exactly as
+they are while giving the client a documented contract with per-frame revisions.
+
+### What the protocol guarantees
+
+Only components whose dependencies changed are requested; the rest are never
+named in the batch. Duplicate notifications for the same batch and revision merge
+into one request, and a superseded request for the same batch is aborted. Each
+frame carries its own revision and cannot overwrite a target that already holds a
+newer one. Authorization is unchanged: every component in the batch passes the
+same `authorize_query` boundary an individual refresh uses, and the batch name is
+signed into the component token, so a browser cannot invent or widen a group. A
+batch mixing actors or groups is rejected.
+
+Components without `batch:` keep issuing their own request, and a scope can mix
+batched and unbatched components freely.
+
## Personalized state payloads
Reactive ERB components cost one browser request per changed component. When a
diff --git a/lib/solid_objects/actor_view.rb b/lib/solid_objects/actor_view.rb
index 9f5c954..e15f23a 100644
--- a/lib/solid_objects/actor_view.rb
+++ b/lib/solid_objects/actor_view.rb
@@ -37,18 +37,19 @@ def value(name)
)
end
- # @rbs (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?, ?key: untyped, ?locals: Hash[untyped, untyped], ?refresh: String | Symbol) -> untyped
+ # @rbs (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?, ?key: untyped, ?locals: Hash[untyped, untyped], ?refresh: String | Symbol, ?batch: untyped) -> untyped
def component(
name,
observes: nil,
partial: nil,
key: nil,
locals: {},
- refresh: :replace
+ refresh: :replace,
+ batch: nil
)
component_name = normalized_component_name(name)
unless observes
- validate_static_options!(key:, locals:, refresh:)
+ validate_static_options!(key:, locals:, refresh:, batch:)
return static_component(component_name, partial:)
end
if partial
@@ -67,7 +68,8 @@ def component(
locals:,
refresh_method: refresh,
snapshot:,
- refresh_path:
+ refresh_path:,
+ batch: batch&.to_s
)
ensure_unique_component!(registration)
rendered = ComponentRenderer.new(
@@ -98,6 +100,11 @@ def scalar_observable_names
observable_names.dup
end
+ # @rbs () -> bool
+ def batched_components?
+ component_registrations.any?(&:batch)
+ end
+
# @rbs () -> bool
def morph_components?
component_registrations.any?(&:morph?)
@@ -212,9 +219,9 @@ def ensure_unique_component!(registration)
"#{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"
+ # @rbs (key: untyped, locals: Hash[untyped, untyped], refresh: String | Symbol, batch: untyped) -> void
+ def validate_static_options!(key:, locals:, refresh:, batch:)
+ return if key.nil? && locals.empty? && refresh.to_s == "replace" && batch.nil?
raise ArgumentError,
"key, locals, and refresh require an observable component"
diff --git a/lib/solid_objects/component_registration.rb b/lib/solid_objects/component_registration.rb
index 020b4a9..f87c6a5 100644
--- a/lib/solid_objects/component_registration.rb
+++ b/lib/solid_objects/component_registration.rb
@@ -7,6 +7,7 @@ class ComponentRegistration
# @rbs @reference: Reference
# @rbs @component_name: String
# @rbs @component_key: String | Integer?
+ # @rbs @batch: String?
# @rbs @dependencies: Array[String]
# @rbs @locals: Hash[String, untyped]
# @rbs @refresh_method: String
@@ -18,6 +19,7 @@ class ComponentRegistration
attr_reader :reference,
:component_name,
:component_key,
+ :batch,
:dependencies,
:locals,
:refresh_method,
@@ -26,7 +28,7 @@ class ComponentRegistration
:refresh_path,
:token
- # @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
+ # @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, ?batch: String?) -> void
def initialize(
reference:,
component_name:,
@@ -37,9 +39,11 @@ def initialize(
instance_id:,
revision:,
refresh_path:,
- token:
+ token:,
+ batch: nil
)
@reference = reference
+ @batch = batch
@component_name = component_name
@component_key = component_key
@dependencies = dependencies.freeze
@@ -52,7 +56,7 @@ def initialize(
end
class << self
- # @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
+ # @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, ?batch: String?) -> ComponentRegistration
def issue(
reference:,
component_name:,
@@ -61,7 +65,8 @@ def issue(
locals:,
refresh_method:,
snapshot:,
- refresh_path:
+ refresh_path:,
+ batch: nil
)
token = ComponentToken.generate(
reference:,
@@ -70,6 +75,7 @@ def issue(
dependencies:,
locals:,
refresh_method:,
+ batch:,
instance_id: snapshot.instance_id,
revision: snapshot.revision,
refresh_path:
@@ -99,6 +105,7 @@ def build(payload, token:)
reference:,
component_name: payload.fetch("component_name"),
component_key: payload["component_key"],
+ batch: payload["batch"],
dependencies:,
locals: payload.fetch("locals"),
refresh_method: payload.fetch("refresh_method"),
@@ -149,6 +156,18 @@ def authorization_arguments
locals.merge("component_key" => component_key).freeze
end
+ # @rbs (Array[ComponentRegistration], Integer, Integer) -> String
+ def batch_refresh_url(registrations, instance_id, revision)
+ query = URI.encode_www_form(
+ [
+ [ "instance_id", instance_id ],
+ [ "revision", revision ],
+ *registrations.map { |registration| [ "tokens[]", registration.token ] }
+ ]
+ )
+ "#{refresh_path}/batch?#{query}"
+ end
+
# @rbs (Integer, Integer) -> String
def refresh_url(instance_id, revision)
query = URI.encode_www_form(
diff --git a/lib/solid_objects/component_subscriptions.rb b/lib/solid_objects/component_subscriptions.rb
index db028d2..5dc502a 100644
--- a/lib/solid_objects/component_subscriptions.rb
+++ b/lib/solid_objects/component_subscriptions.rb
@@ -48,16 +48,17 @@ def refreshes_for(invalidation)
observable_name = invalidation.fetch("observable_name")
instance_id = invalidation.fetch("instance_id")
revision = invalidation.fetch("revision")
- registrations.filter_map do |registration|
- next unless registration.dependencies.include?(observable_name)
- next unless newer_revision?(
- registration.dom_id,
- instance_id,
- revision
- )
-
- refresh(registration, instance_id, revision)
+ changed = registrations.select do |registration|
+ registration.dependencies.include?(observable_name) &&
+ newer_revision?(registration.dom_id, instance_id, revision)
end
+ batched, individual = changed.partition(&:batch)
+ streams = individual.map { |registration| refresh(registration, instance_id, revision) }
+ batched.group_by(&:batch).each_value do |group|
+ group.each { |registration| record_revision(registration, instance_id, revision) }
+ streams << TurboStreamRenderer.batch_refresh(group, instance_id, revision)
+ end
+ streams
end
# @rbs (ActorSnapshot) -> Array[String]
@@ -90,9 +91,14 @@ def validate_identity!(registration, reference)
attr_reader :registrations, :revisions
+ # @rbs (ComponentRegistration, Integer, Integer) -> void
+ def record_revision(registration, instance_id, revision)
+ revisions[registration.dom_id] = [ instance_id, revision ]
+ end
+
# @rbs (ComponentRegistration, Integer, Integer) -> String
def refresh(registration, instance_id, revision)
- revisions[registration.dom_id] = [ instance_id, revision ]
+ record_revision(registration, instance_id, revision)
TurboStreamRenderer.component_refresh(
registration,
instance_id,
diff --git a/lib/solid_objects/component_token.rb b/lib/solid_objects/component_token.rb
index 1bdd6a8..a01090d 100644
--- a/lib/solid_objects/component_token.rb
+++ b/lib/solid_objects/component_token.rb
@@ -9,6 +9,7 @@ module ComponentToken
MAXIMUM_DEPENDENCIES = 50
MAXIMUM_LOCALS = 50
MAXIMUM_COMPONENT_KEY_BYTES = 512
+ MAXIMUM_BATCH_BYTES = 64
REFRESH_METHODS = %w[replace morph].freeze
RESERVED_LOCALS = %w[actor authorization_context component_key].freeze
RUBY_KEYWORDS = %w[
@@ -19,7 +20,7 @@ module ComponentToken
module_function
- # @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
+ # @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, ?batch: untyped) -> String
def generate(
reference:,
component_name:,
@@ -29,13 +30,15 @@ def generate(
refresh_path:,
component_key: nil,
locals: {},
- refresh_method: "replace"
+ refresh_method: "replace",
+ batch: nil
)
payload = {
"actor_type" => reference.actor_type,
"actor_id" => reference.actor_id,
"component_name" => component_name,
"component_key" => Serialization.dump(component_key),
+ "batch" => batch&.to_s,
"dependencies" => dependencies,
"locals" => Serialization.dump(locals),
"refresh_method" => refresh_method.to_s,
@@ -80,6 +83,7 @@ def validate_payload!(payload)
validate_component_name!(payload.fetch("component_name"))
validate_component_key!(payload["component_key"])
+ validate_batch!(payload["batch"])
validate_dependencies!(payload.fetch("dependencies"))
validate_locals!(payload.fetch("locals"))
validate_refresh_method!(payload.fetch("refresh_method"))
@@ -97,6 +101,7 @@ def apply_defaults!(payload)
return unless payload.is_a?(Hash)
payload["component_key"] = nil unless payload.key?("component_key")
+ payload["batch"] = nil unless payload.key?("batch")
payload["locals"] = {} unless payload.key?("locals")
payload["refresh_method"] = "replace" unless payload.key?("refresh_method")
end
@@ -124,6 +129,18 @@ def validate_component_key!(component_key)
end
private_class_method :validate_component_key!
+ # @rbs (untyped) -> void
+ def validate_batch!(batch)
+ return if batch.nil?
+ return if batch.is_a?(String) &&
+ batch.bytesize.positive? &&
+ batch.bytesize <= MAXIMUM_BATCH_BYTES &&
+ batch.match?(/\A[a-zA-Z0-9_]+\z/)
+
+ raise InvalidComponentToken, "invalid actor component batch"
+ end
+ private_class_method :validate_batch!
+
# @rbs (Array[untyped]) -> void
def validate_dependencies!(dependencies)
valid = dependencies.any? &&
diff --git a/lib/solid_objects/turbo_stream_renderer.rb b/lib/solid_objects/turbo_stream_renderer.rb
index d4b1c96..ed1881a 100644
--- a/lib/solid_objects/turbo_stream_renderer.rb
+++ b/lib/solid_objects/turbo_stream_renderer.rb
@@ -54,6 +54,18 @@ def component_refresh(registration, instance_id, revision)
%()
end
+ # @rbs (Array[ComponentRegistration], Integer, Integer) -> String
+ def batch_refresh(registrations, instance_id, revision)
+ first = registrations.first
+ scope = DomIdentity.scope(first.reference)
+ batch = ERB::Util.html_escape(first.batch)
+ source = ERB::Util.html_escape(
+ first.batch_refresh_url(registrations, instance_id, revision)
+ )
+ targets = ERB::Util.html_escape(registrations.map(&:dom_id).join(" "))
+ %()
+ end
+
# @rbs (Hash[String, untyped]) -> String
def state_payload(payload)
reference = Reference.new(
diff --git a/lib/solid_objects/version.rb b/lib/solid_objects/version.rb
index a95196f..d1d1156 100644
--- a/lib/solid_objects/version.rb
+++ b/lib/solid_objects/version.rb
@@ -1,5 +1,5 @@
# rbs_inline: enabled
module SolidObjects
- VERSION = "0.6.0"
+ VERSION = "0.7.0"
end
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..22420f0
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,543 @@
+{
+ "name": "solid_objects",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "solid_objects",
+ "devDependencies": {
+ "jsdom": "^26.1.0"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsdom": {
+ "version": "26.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
+ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssstyle": "^4.2.1",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.5.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.16",
+ "parse5": "^7.2.1",
+ "rrweb-cssom": "^0.8.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^5.1.1",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.1.1",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nwsapi": {
+ "version": "2.2.24",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
+ "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
+ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..f159960
--- /dev/null
+++ b/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "solid_objects",
+ "private": true,
+ "type": "module",
+ "description": "Browser modules for the Solid Objects Rails engine",
+ "scripts": {
+ "test": "node --test test/javascript/*.test.mjs"
+ },
+ "devDependencies": {
+ "jsdom": "^26.1.0"
+ }
+}
diff --git a/sig/generated/controllers/solid_objects/components_controller.rbs b/sig/generated/controllers/solid_objects/components_controller.rbs
index 09d4239..3881165 100644
--- a/sig/generated/controllers/solid_objects/components_controller.rbs
+++ b/sig/generated/controllers/solid_objects/components_controller.rbs
@@ -2,11 +2,22 @@
module SolidObjects
class ComponentsController < ActionController::Base
+ BATCH_LIMIT: ::Integer
+
# @rbs () -> void
def show: () -> void
+ # @rbs () -> void
+ def batch: () -> void
+
private
+ # @rbs (Hash[Symbol, untyped]) -> void
+ def refresh_batch: (Hash[Symbol, untyped]) -> void
+
+ # @rbs (Array[ComponentRegistration]) -> void
+ def validate_single_batch!: (Array[ComponentRegistration]) -> void
+
# @rbs (Hash[Symbol, untyped]) -> void
def refresh: (Hash[Symbol, untyped]) -> void
diff --git a/sig/generated/lib/solid_objects/actor_view.rbs b/sig/generated/lib/solid_objects/actor_view.rbs
index ba9d869..43ce086 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?, ?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 (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?, ?key: untyped, ?locals: Hash[untyped, untyped], ?refresh: String | Symbol, ?batch: untyped) -> untyped
+ def component: (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?, ?key: untyped, ?locals: Hash[untyped, untyped], ?refresh: String | Symbol, ?batch: untyped) -> 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 batched_components?: () -> bool
+
# @rbs () -> bool
def morph_components?: () -> bool
@@ -79,8 +82,8 @@ module SolidObjects
# @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 (key: untyped, locals: Hash[untyped, untyped], refresh: String | Symbol, batch: untyped) -> void
+ def validate_static_options!: (key: untyped, locals: Hash[untyped, untyped], refresh: String | Symbol, batch: untyped) -> 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 7df0aec..a6b7387 100644
--- a/sig/generated/lib/solid_objects/component_registration.rbs
+++ b/sig/generated/lib/solid_objects/component_registration.rbs
@@ -16,6 +16,8 @@ module SolidObjects
@dependencies: Array[String]
+ @batch: String?
+
@component_key: String | Integer?
@component_name: String
@@ -28,6 +30,8 @@ module SolidObjects
attr_reader component_key: untyped
+ attr_reader batch: untyped
+
attr_reader dependencies: untyped
attr_reader locals: untyped
@@ -42,11 +46,11 @@ module SolidObjects
attr_reader token: untyped
- # @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, component_key: String | Integer?, dependencies: Array[String], locals: Hash[String, untyped], refresh_method: String, instance_id: Integer, revision: Integer, refresh_path: String, token: String, ?batch: 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, ?batch: String?) -> void
- # @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 (reference: Reference, component_name: String, component_key: untyped, dependencies: Array[String], locals: Hash[untyped, untyped], refresh_method: String | Symbol, snapshot: ActorSnapshot, refresh_path: String, ?batch: 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, ?batch: String?) -> ComponentRegistration
# @rbs (String) -> ComponentRegistration
def self.from_token: (String) -> ComponentRegistration
@@ -69,6 +73,9 @@ module SolidObjects
# @rbs () -> Hash[String, untyped]
def authorization_arguments: () -> Hash[String, untyped]
+ # @rbs (Array[ComponentRegistration], Integer, Integer) -> String
+ def batch_refresh_url: (Array[ComponentRegistration], Integer, Integer) -> String
+
# @rbs (Integer, Integer) -> String
def refresh_url: (Integer, Integer) -> String
end
diff --git a/sig/generated/lib/solid_objects/component_subscriptions.rbs b/sig/generated/lib/solid_objects/component_subscriptions.rbs
index 3cd503a..8b4746c 100644
--- a/sig/generated/lib/solid_objects/component_subscriptions.rbs
+++ b/sig/generated/lib/solid_objects/component_subscriptions.rbs
@@ -31,6 +31,9 @@ module SolidObjects
attr_reader revisions: untyped
+ # @rbs (ComponentRegistration, Integer, Integer) -> void
+ def record_revision: (ComponentRegistration, Integer, Integer) -> void
+
# @rbs (ComponentRegistration, Integer, Integer) -> String
def refresh: (ComponentRegistration, Integer, Integer) -> String
diff --git a/sig/generated/lib/solid_objects/component_token.rbs b/sig/generated/lib/solid_objects/component_token.rbs
index ac29aa5..f2065c0 100644
--- a/sig/generated/lib/solid_objects/component_token.rbs
+++ b/sig/generated/lib/solid_objects/component_token.rbs
@@ -12,14 +12,16 @@ module SolidObjects
MAXIMUM_COMPONENT_KEY_BYTES: ::Integer
+ MAXIMUM_BATCH_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 (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, ?batch: untyped) -> 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, ?batch: untyped) -> String
# @rbs (String) -> Hash[String, untyped]
def self?.verify: (String) -> Hash[String, untyped]
@@ -36,6 +38,9 @@ module SolidObjects
# @rbs (untyped) -> void
def self?.validate_component_key!: (untyped) -> void
+ # @rbs (untyped) -> void
+ def self?.validate_batch!: (untyped) -> void
+
# @rbs (Array[untyped]) -> void
def self?.validate_dependencies!: (Array[untyped]) -> void
diff --git a/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs b/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs
index 3fd511a..15cb61f 100644
--- a/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs
+++ b/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs
@@ -15,6 +15,9 @@ module SolidObjects
# @rbs (ComponentRegistration, Integer, Integer) -> String
def self?.component_refresh: (ComponentRegistration, Integer, Integer) -> String
+ # @rbs (Array[ComponentRegistration], Integer, Integer) -> String
+ def self?.batch_refresh: (Array[ComponentRegistration], Integer, Integer) -> String
+
# @rbs (Hash[String, untyped]) -> String
def self?.state_payload: (Hash[String, untyped]) -> String
diff --git a/test/integration/component_batch_test.rb b/test/integration/component_batch_test.rb
new file mode 100644
index 0000000..51bb399
--- /dev/null
+++ b/test/integration/component_batch_test.rb
@@ -0,0 +1,200 @@
+# frozen_string_literal: true
+
+require "database_test_helper"
+
+class ComponentBatchTest < ActiveSupport::TestCase
+ class BoardActor < SolidObjects::Actor
+ actor_type "batch-board"
+
+ attribute :player, default: "alice"
+ attribute :controls, default: -> { [] }
+ attribute :library, default: -> { [] }
+ attribute :chat, default: -> { [] }
+
+ observable :player
+ observable :controls
+ observable :library
+ observable :chat
+ end
+
+ setup do
+ BoardActor.ensure_registered!
+ SolidObjects.configuration.stream_signing_secret = "batch-test-secret"
+ SolidObjects.configuration.authorize_query = ->(**) { true }
+ end
+
+ test "components in one batch produce a single refresh element" do
+ subscriptions = subscriptions_for(
+ player: "playmat",
+ controls: "playmat",
+ library: "playmat"
+ )
+
+ streams = subscriptions.refreshes_for(invalidation("player"))
+
+ assert_equal 1, streams.length
+ assert_includes streams.first, "solid-objects-batch-refresh"
+ assert_includes streams.first, %(data-batch="playmat")
+ end
+
+ test "the batch element carries only the components whose dependency changed" do
+ subscriptions = subscriptions_for(
+ player: "playmat",
+ controls: "playmat",
+ library: "playmat"
+ )
+
+ streams = subscriptions.refreshes_for(invalidation("player"))
+
+ targets = streams.first[/data-targets="([^"]+)"/, 1].split
+ assert_equal 1, targets.length
+ assert_match(/player/, targets.first)
+ end
+
+ test "one batch element covers several components changing together" do
+ subscriptions = subscriptions_for(
+ player: "playmat",
+ controls: "playmat"
+ )
+ combined = SolidObjects::ComponentSubscriptions.new(
+ registrations(player: "playmat", controls: "playmat")
+ )
+
+ streams = combined.refreshes_for(invalidation("player")) +
+ combined.refreshes_for(invalidation("controls"))
+
+ assert_equal 2, streams.length
+ assert(streams.all? { |stream| stream.include?("solid-objects-batch-refresh") })
+ assert_equal 1, streams.map { |s| s[/data-revision="([^"]+)"/, 1] }.uniq.length
+ refute_nil subscriptions
+ end
+
+ test "a stale revision produces no refresh" do
+ subscriptions = subscriptions_for(player: "playmat")
+
+ assert_empty subscriptions.refreshes_for(invalidation("player", revision: 0))
+ end
+
+ test "a repeated revision produces no second refresh" do
+ subscriptions = subscriptions_for(player: "playmat")
+
+ first = subscriptions.refreshes_for(invalidation("player"))
+ second = subscriptions.refreshes_for(invalidation("player"))
+
+ assert_equal 1, first.length
+ assert_empty second
+ end
+
+ test "separate batches refresh independently" do
+ subscriptions = SolidObjects::ComponentSubscriptions.new(
+ registrations(player: "playmat", chat: "sidebar")
+ )
+
+ streams = subscriptions.refreshes_for(invalidation("player")) +
+ subscriptions.refreshes_for(invalidation("chat"))
+
+ batches = streams.map { |stream| stream[/data-batch="([^"]+)"/, 1] }
+ assert_equal %w[playmat sidebar], batches
+ end
+
+ test "unbatched components keep the existing one-request behaviour" do
+ subscriptions = SolidObjects::ComponentSubscriptions.new(
+ registrations(player: nil, controls: nil)
+ )
+
+ streams = subscriptions.refreshes_for(invalidation("player"))
+
+ assert_equal 1, streams.length
+ refute_includes streams.first, "solid-objects-batch-refresh"
+ end
+
+ test "a batch mixes with unbatched components in one scope" do
+ subscriptions = SolidObjects::ComponentSubscriptions.new(
+ registrations(player: "playmat", controls: nil)
+ )
+
+ batched = subscriptions.refreshes_for(invalidation("player"))
+ unbatched = subscriptions.refreshes_for(invalidation("controls"))
+
+ assert_includes batched.first, "solid-objects-batch-refresh"
+ refute_includes unbatched.first, "solid-objects-batch-refresh"
+ end
+
+ test "the batch name is signed into the component token" do
+ registration = registrations(player: "playmat").first
+
+ assert_equal "playmat", registration.batch
+ assert_equal(
+ "playmat",
+ SolidObjects::ComponentRegistration.from_token(registration.token).batch
+ )
+ end
+
+ test "rejects a forged batch name" do
+ assert_raises SolidObjects::InvalidComponentToken do
+ SolidObjects::ComponentToken.generate(
+ reference: BoardActor.ref("table"),
+ component_name: "player",
+ component_key: nil,
+ dependencies: %w[player],
+ locals: {},
+ refresh_method: "morph",
+ instance_id: 1,
+ revision: 1,
+ refresh_path: "/solid_objects/components",
+ batch: "not a batch"
+ )
+ end
+ end
+
+ test "the batch url requests every changed component once" do
+ group = registrations(player: "playmat", controls: "playmat")
+
+ url = group.first.batch_refresh_url(group, 5, 9)
+
+ assert_includes url, "/solid_objects/components/batch?"
+ assert_equal 2, url.scan("tokens%5B%5D=").length
+ assert_includes url, "instance_id=5"
+ assert_includes url, "revision=9"
+ end
+
+ private
+
+ def reference
+ BoardActor.ref("table")
+ end
+
+ def snapshot
+ SolidObjects::ActorSnapshot.new(reference)
+ end
+
+ def registrations(**batches)
+ current = snapshot
+ batches.map do |component_name, batch|
+ SolidObjects::ComponentRegistration.issue(
+ reference:,
+ component_name: component_name.to_s,
+ component_key: nil,
+ dependencies: [ component_name.to_s ],
+ locals: {},
+ refresh_method: "morph",
+ snapshot: current,
+ refresh_path: "/solid_objects/components",
+ batch:
+ )
+ end
+ end
+
+ def subscriptions_for(**batches)
+ SolidObjects::ComponentSubscriptions.new(registrations(**batches))
+ end
+
+ def invalidation(observable_name, revision: nil)
+ current = snapshot
+ {
+ "observable_name" => observable_name.to_s,
+ "instance_id" => current.instance_id,
+ "revision" => revision || current.revision + 1
+ }
+ end
+end
diff --git a/test/javascript/browser_test_helper.mjs b/test/javascript/browser_test_helper.mjs
new file mode 100644
index 0000000..fed1f2d
--- /dev/null
+++ b/test/javascript/browser_test_helper.mjs
@@ -0,0 +1,43 @@
+import { JSDOM } from "jsdom"
+
+const MODULE_ROOT = new URL(
+ "../../app/assets/javascripts/solid_objects/",
+ import.meta.url
+)
+
+// Each suite gets one document. Custom element registries are per-window, and
+// the modules register on import, so the window has to exist first.
+export function openDocument(body = "") {
+ const dom = new JSDOM(`
${body}`, {
+ url: "https://example.test/",
+ runScripts: "outside-only"
+ })
+
+ globalThis.window = dom.window
+ globalThis.document = dom.window.document
+ globalThis.HTMLElement = dom.window.HTMLElement
+ globalThis.customElements = dom.window.customElements
+ globalThis.CustomEvent = dom.window.CustomEvent
+ globalThis.DOMParser = dom.window.DOMParser
+ globalThis.AbortController = dom.window.AbortController
+
+ return dom
+}
+
+export async function loadModule(name) {
+ // Cache-bust so each suite re-registers against its own window.
+ return import(`${MODULE_ROOT}${name}?cache=${Math.random()}`)
+}
+
+export function nextTick() {
+ return new Promise((resolve) => setTimeout(resolve, 0))
+}
+
+export function appendPayload(scope, { name, revision, payload }) {
+ const element = document.createElement("solid-objects-payload")
+ element.dataset.name = name
+ element.dataset.revision = revision
+ element.textContent = JSON.stringify(payload)
+ scope.append(element)
+ return element
+}
diff --git a/test/javascript/component_batch_refresh.test.mjs b/test/javascript/component_batch_refresh.test.mjs
new file mode 100644
index 0000000..e0af153
--- /dev/null
+++ b/test/javascript/component_batch_refresh.test.mjs
@@ -0,0 +1,212 @@
+import assert from "node:assert/strict"
+import { test, before, beforeEach } from "node:test"
+import { openDocument, loadModule, nextTick } from "./browser_test_helper.mjs"
+
+const ENDPOINT = "/solid_objects/components/batch"
+
+let requests
+let responder
+let defaultScope
+let scopeCount = 0
+
+before(async () => {
+ const dom = openDocument()
+ dom.window.fetch = async (url) => {
+ requests.push(url.toString())
+ return responder(url)
+ }
+ globalThis.fetch = dom.window.fetch
+ await loadModule("component_batch_refresh.js")
+})
+
+beforeEach(() => {
+ document.body.innerHTML = ""
+ // Applied frames are appended to documentElement, outside body.
+ document.querySelectorAll("turbo-stream").forEach((stream) => stream.remove())
+ requests = []
+ responder = async () => jsonResponse({ frames: [] })
+ defaultScope = scopeNamed(`solid-objects-scope-${(scopeCount += 1)}`)
+})
+
+function jsonResponse(body, { ok = true, status = 200 } = {}) {
+ return {
+ ok,
+ status,
+ json: async () => body
+ }
+}
+
+function frameHtml(target, revision) {
+ return `fresh`
+}
+
+function addTarget(target, revision) {
+ const frame = document.createElement("turbo-frame")
+ frame.id = target
+ frame.dataset.solidObjectsRevision = revision
+ frame.textContent = "stale"
+ document.body.append(frame)
+ return frame
+}
+
+function scopeNamed(id) {
+ const scope = document.createElement("div")
+ scope.id = id
+ document.body.append(scope)
+ return scope
+}
+
+function notify({ batch, revision, targets, tokens, scope }) {
+ const element = document.createElement("solid-objects-batch-refresh")
+ element.dataset.batch = batch
+ element.dataset.revision = revision
+ element.dataset.targets = targets.join(" ")
+ const query = tokens.map((token) => `tokens[]=${token}`).join("&")
+ element.dataset.source = `${ENDPOINT}?instance_id=1&revision=9&${query}`
+ ;(scope ?? defaultScope).append(element)
+ return element
+}
+
+test("three components changing together issue one request", async () => {
+ notify({ batch: "playmat", revision: "1:9", targets: [ "player" ], tokens: [ "t1" ] })
+ notify({ batch: "playmat", revision: "1:9", targets: [ "controls" ], tokens: [ "t2" ] })
+ notify({ batch: "playmat", revision: "1:9", targets: [ "library" ], tokens: [ "t3" ] })
+ await nextTick()
+
+ assert.equal(requests.length, 1)
+})
+
+test("the merged request asks for every changed component", async () => {
+ notify({ batch: "playmat", revision: "1:9", targets: [ "player" ], tokens: [ "t1" ] })
+ notify({ batch: "playmat", revision: "1:9", targets: [ "controls" ], tokens: [ "t2" ] })
+ await nextTick()
+
+ const url = new URL(requests[0], "https://example.test/")
+ assert.deepEqual(url.searchParams.getAll("tokens[]").sort(), [ "t1", "t2" ])
+})
+
+test("a token requested twice is only sent once", async () => {
+ notify({ batch: "playmat", revision: "1:9", targets: [ "player" ], tokens: [ "t1" ] })
+ notify({ batch: "playmat", revision: "1:9", targets: [ "player" ], tokens: [ "t1" ] })
+ await nextTick()
+
+ const url = new URL(requests[0], "https://example.test/")
+ assert.deepEqual(url.searchParams.getAll("tokens[]"), [ "t1" ])
+})
+
+test("separate batches issue separate requests", async () => {
+ notify({ batch: "playmat", revision: "1:9", targets: [ "player" ], tokens: [ "t1" ] })
+ notify({ batch: "sidebar", revision: "1:9", targets: [ "chat" ], tokens: [ "t2" ] })
+ await nextTick()
+
+ assert.equal(requests.length, 2)
+})
+
+test("a later revision issues its own request", async () => {
+ notify({ batch: "playmat", revision: "1:9", targets: [ "player" ], tokens: [ "t1" ] })
+ await nextTick()
+ notify({ batch: "playmat", revision: "1:10", targets: [ "player" ], tokens: [ "t1" ] })
+ await nextTick()
+
+ assert.equal(requests.length, 2)
+})
+
+test("applies a newer frame to its target", async () => {
+ addTarget("player", "1:8")
+ responder = async () => jsonResponse({
+ frames: [ { target: "player", revision: "1:9", html: frameHtml("player", "1:9") } ]
+ })
+ notify({ batch: "playmat", revision: "1:9", targets: [ "player" ], tokens: [ "t1" ] })
+ await nextTick()
+
+ const stream = document.querySelector("turbo-stream[target='player']")
+ assert.ok(stream, "expected a turbo-stream to be emitted for the target")
+})
+
+test("a stale frame cannot overwrite a newer target", async () => {
+ addTarget("player", "1:12")
+ responder = async () => jsonResponse({
+ frames: [ { target: "player", revision: "1:9", html: frameHtml("player", "1:9") } ]
+ })
+ notify({ batch: "playmat", revision: "1:9", targets: [ "player" ], tokens: [ "t1" ] })
+ await nextTick()
+
+ assert.equal(document.querySelector("turbo-stream[target='player']"), null)
+})
+
+test("a frame for an absent target is ignored", async () => {
+ responder = async () => jsonResponse({
+ frames: [ { target: "missing", revision: "1:9", html: frameHtml("missing", "1:9") } ]
+ })
+ notify({ batch: "playmat", revision: "1:9", targets: [ "missing" ], tokens: [ "t1" ] })
+ await nextTick()
+
+ assert.equal(document.querySelector("turbo-stream"), null)
+})
+
+test("reports a failed response", async () => {
+ const errors = []
+ document.addEventListener("solid-objects:batch-refresh-error", (event) => {
+ errors.push(event.detail.reason)
+ })
+ responder = async () => jsonResponse({}, { ok: false, status: 403 })
+ notify({ batch: "playmat", revision: "1:9", targets: [ "player" ], tokens: [ "t1" ] })
+ await nextTick()
+
+ assert.deepEqual(errors, [ "http_403" ])
+})
+
+test("reports a malformed response", async () => {
+ const errors = []
+ document.addEventListener("solid-objects:batch-refresh-error", (event) => {
+ errors.push(event.detail.reason)
+ })
+ responder = async () => jsonResponse({ frames: "nope" })
+ notify({ batch: "playmat", revision: "1:9", targets: [ "player" ], tokens: [ "t1" ] })
+ await nextTick()
+
+ assert.deepEqual(errors, [ "invalid_response" ])
+})
+
+test("the notification element removes itself", async () => {
+ const element = notify({
+ batch: "playmat",
+ revision: "1:9",
+ targets: [ "player" ],
+ tokens: [ "t1" ]
+ })
+ await nextTick()
+
+ assert.equal(element.isConnected, false)
+})
+
+test("two scopes sharing a batch name do not merge requests", async () => {
+ const first = scopeNamed("solid-objects-scope-table-1")
+ const second = scopeNamed("solid-objects-scope-table-2")
+ notify({ batch: "playmat", revision: "1:9", targets: [ "a" ], tokens: [ "t1" ], scope: first })
+ notify({ batch: "playmat", revision: "1:9", targets: [ "b" ], tokens: [ "t2" ], scope: second })
+ await nextTick()
+
+ assert.equal(requests.length, 2)
+ const tokens = requests.map(
+ (request) => new URL(request, "https://example.test/").searchParams.getAll("tokens[]")
+ )
+ assert.deepEqual(tokens, [ [ "t1" ], [ "t2" ] ])
+})
+
+test("one scope does not abort another scope's request", async () => {
+ const first = scopeNamed("solid-objects-scope-table-3")
+ const second = scopeNamed("solid-objects-scope-table-4")
+ const aborted = []
+ responder = async (url) => {
+ await new Promise((resolve) => setTimeout(resolve, 5))
+ return jsonResponse({ frames: [] })
+ }
+ notify({ batch: "playmat", revision: "1:9", targets: [ "a" ], tokens: [ "t1" ], scope: first })
+ await nextTick()
+ notify({ batch: "playmat", revision: "1:10", targets: [ "b" ], tokens: [ "t2" ], scope: second })
+ await new Promise((resolve) => setTimeout(resolve, 20))
+
+ assert.equal(requests.length, 2)
+ assert.deepEqual(aborted, [])
+})
diff --git a/test/javascript/state_payload.test.mjs b/test/javascript/state_payload.test.mjs
new file mode 100644
index 0000000..219632c
--- /dev/null
+++ b/test/javascript/state_payload.test.mjs
@@ -0,0 +1,140 @@
+import assert from "node:assert/strict"
+import { test, before, beforeEach } from "node:test"
+import {
+ openDocument,
+ loadModule,
+ nextTick,
+ appendPayload
+} from "./browser_test_helper.mjs"
+
+before(async () => {
+ openDocument()
+ await loadModule("state_payload.js")
+})
+
+let scope
+let received
+let scopeCount = 0
+
+beforeEach(() => {
+ document.body.innerHTML = ""
+ scope = document.createElement("div")
+ // The module tracks delivered revisions per scope for the life of the page,
+ // so each test needs its own scope identity.
+ scope.id = `solid-objects-scope-${(scopeCount += 1)}`
+ document.body.append(scope)
+ received = []
+ scope.addEventListener("solid-objects:payload", (event) => {
+ received.push(event.detail)
+ })
+})
+
+test("dispatches the payload with its identity and revision", async () => {
+ appendPayload(scope, {
+ name: "playmat_state",
+ revision: "7:12",
+ payload: { turn: 3, hand: ["Island"] }
+ })
+ await nextTick()
+
+ assert.equal(received.length, 1)
+ assert.equal(received[0].name, "playmat_state")
+ assert.equal(received[0].instanceId, 7)
+ assert.equal(received[0].revision, 12)
+ assert.deepEqual(received[0].payload, { turn: 3, hand: ["Island"] })
+})
+
+test("removes itself after delivering", async () => {
+ const element = appendPayload(scope, {
+ name: "playmat_state",
+ revision: "7:12",
+ payload: {}
+ })
+ await nextTick()
+
+ assert.equal(element.isConnected, false)
+})
+
+test("drops a stale revision", async () => {
+ appendPayload(scope, { name: "playmat_state", revision: "7:12", payload: { turn: 3 } })
+ await nextTick()
+ appendPayload(scope, { name: "playmat_state", revision: "7:9", payload: { turn: 1 } })
+ await nextTick()
+
+ assert.equal(received.length, 1)
+ assert.equal(received[0].revision, 12)
+})
+
+test("drops a duplicate revision", async () => {
+ appendPayload(scope, { name: "playmat_state", revision: "7:12", payload: {} })
+ await nextTick()
+ appendPayload(scope, { name: "playmat_state", revision: "7:12", payload: {} })
+ await nextTick()
+
+ assert.equal(received.length, 1)
+})
+
+test("accepts a newer revision after a stale one", async () => {
+ appendPayload(scope, { name: "playmat_state", revision: "7:12", payload: {} })
+ await nextTick()
+ appendPayload(scope, { name: "playmat_state", revision: "7:9", payload: {} })
+ await nextTick()
+ appendPayload(scope, { name: "playmat_state", revision: "7:13", payload: {} })
+ await nextTick()
+
+ assert.deepEqual(received.map((detail) => detail.revision), [12, 13])
+})
+
+test("a newer instance supersedes an older one", async () => {
+ appendPayload(scope, { name: "playmat_state", revision: "7:99", payload: {} })
+ await nextTick()
+ appendPayload(scope, { name: "playmat_state", revision: "8:1", payload: {} })
+ await nextTick()
+
+ assert.deepEqual(received.map((detail) => detail.instanceId), [7, 8])
+})
+
+test("tracks revisions per payload name", async () => {
+ appendPayload(scope, { name: "playmat_state", revision: "7:12", payload: {} })
+ await nextTick()
+ appendPayload(scope, { name: "sideboard_state", revision: "7:5", payload: {} })
+ await nextTick()
+
+ assert.deepEqual(received.map((detail) => detail.name), [
+ "playmat_state",
+ "sideboard_state"
+ ])
+})
+
+test("reports malformed payloads without dispatching", async () => {
+ const errors = []
+ scope.addEventListener("solid-objects:payload-error", (event) => {
+ errors.push(event.detail.reason)
+ })
+ const element = document.createElement("solid-objects-payload")
+ element.dataset.name = "playmat_state"
+ element.dataset.revision = "7:12"
+ element.textContent = "{not json"
+ scope.append(element)
+ await nextTick()
+
+ assert.equal(received.length, 0)
+ assert.deepEqual(errors, ["invalid_payload"])
+})
+
+test("ignores an element with no revision", async () => {
+ const element = document.createElement("solid-objects-payload")
+ element.dataset.name = "playmat_state"
+ element.textContent = "{}"
+ scope.append(element)
+ await nextTick()
+
+ assert.equal(received.length, 0)
+})
+
+test("ignores a malformed revision", async () => {
+ appendPayload(scope, { name: "playmat_state", revision: "seven:twelve", payload: {} })
+ await nextTick()
+
+ assert.equal(received.length, 0)
+})