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) +})