solid_objects 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/app/assets/javascripts/solid_objects/component_batch_refresh.js +148 -0
- data/app/assets/javascripts/solid_objects/state_payload.js +69 -0
- data/app/controllers/solid_objects/components_controller.rb +84 -0
- data/app/helpers/solid_objects/actor_helper.rb +22 -4
- data/config/routes.rb +1 -0
- data/docs/realtime.md +161 -0
- data/lib/solid_objects/actor.rb +7 -0
- data/lib/solid_objects/actor_channel.rb +55 -4
- data/lib/solid_objects/actor_definition.rb +19 -0
- data/lib/solid_objects/actor_view.rb +14 -7
- data/lib/solid_objects/component_registration.rb +23 -4
- data/lib/solid_objects/component_subscriptions.rb +16 -10
- data/lib/solid_objects/component_token.rb +19 -2
- data/lib/solid_objects/errors.rb +6 -0
- data/lib/solid_objects/executor.rb +22 -7
- data/lib/solid_objects/payload_broadcast.rb +67 -0
- data/lib/solid_objects/stream_token.rb +19 -12
- data/lib/solid_objects/turbo_stream_renderer.rb +34 -5
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects.rb +1 -0
- data/sig/generated/controllers/solid_objects/components_controller.rbs +11 -0
- data/sig/generated/helpers/solid_objects/actor_helper.rbs +2 -2
- data/sig/generated/lib/solid_objects/actor.rbs +3 -0
- data/sig/generated/lib/solid_objects/actor_channel.rbs +11 -0
- data/sig/generated/lib/solid_objects/actor_definition.rbs +7 -0
- data/sig/generated/lib/solid_objects/actor_view.rbs +7 -4
- data/sig/generated/lib/solid_objects/component_registration.rbs +11 -4
- data/sig/generated/lib/solid_objects/component_subscriptions.rbs +3 -0
- data/sig/generated/lib/solid_objects/component_token.rbs +7 -2
- data/sig/generated/lib/solid_objects/errors.rbs +6 -0
- data/sig/generated/lib/solid_objects/executor.rbs +7 -4
- data/sig/generated/lib/solid_objects/payload_broadcast.rbs +35 -0
- data/sig/generated/lib/solid_objects/stream_token.rbs +5 -2
- data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +6 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0614e42e260b81ab323d292435cd4f518a4f0b26a21865e5f63c9768db362231
|
|
4
|
+
data.tar.gz: a8c7d4bc22e431b89ab07058c0ad35ca82a44eef08d30b0a547b28c0dd76f82e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 07caec47a1c3452bb3f3b6fc6f950f60f9c68277ebb558630d112fcc7bfb9e3591f893e8bdaefea4329680cde6d6a86c36c0d9b6531d6ac889507abfda939920
|
|
7
|
+
data.tar.gz: 23d756629a2b8212d06077569b9d0c35552c6ee416c95851ee78357ecfe1abf672e0a973d9715f0b66942ecebf8e1c007f21d33bde3bfb7e79af30f323a809bf
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0 - 2026-08-09
|
|
4
|
+
|
|
5
|
+
- Add `batch:` to reactive components. Components sharing a batch in one actor
|
|
6
|
+
scope collapse into a single browser request per revision instead of one
|
|
7
|
+
request per component. The new `GET /solid_objects/components/batch` endpoint
|
|
8
|
+
returns HTML frames inside a documented JSON envelope, so Turbo morph and ERB
|
|
9
|
+
rendering are unchanged while the contract stays machine readable. Duplicate
|
|
10
|
+
notifications for the same batch and revision coalesce in the browser,
|
|
11
|
+
unchanged components are never requested, and stale frames cannot overwrite a
|
|
12
|
+
newer target. Components without `batch:` behave exactly as before.
|
|
13
|
+
- Add a JavaScript test suite for the browser modules, run in CI with Node's
|
|
14
|
+
test runner and jsdom.
|
|
15
|
+
|
|
16
|
+
## 0.6.0 - 2026-08-09
|
|
17
|
+
|
|
18
|
+
- Add `broadcast_payload`, an actor DSL for sending one personalized JSON state
|
|
19
|
+
payload over the actor stream a page already has open. The block runs once per
|
|
20
|
+
subscriber with that subscriber's authorization context, so private state
|
|
21
|
+
never crosses sessions. Payloads carry actor identity and the monotonic state
|
|
22
|
+
revision, and both the channel and the browser drop stale revisions. Subscribe
|
|
23
|
+
with `solid_object room, payloads: :playmat_state` and handle the
|
|
24
|
+
`solid-objects:payload` DOM event. ERB component refreshes remain the default
|
|
25
|
+
and are unchanged. A mutation that changes payload state without changing a
|
|
26
|
+
declared observable still invalidates subscribers, through a revision-only
|
|
27
|
+
broadcast that carries no observable value to the browser.
|
|
28
|
+
|
|
3
29
|
## 0.5.2 - 2026-08-09
|
|
4
30
|
|
|
5
31
|
- Read the database clock once per transaction instead of once per step, and
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const pendingBatches = new Map()
|
|
2
|
+
const activeBatches = new Map()
|
|
3
|
+
|
|
4
|
+
class SolidObjectsBatchRefreshElement extends HTMLElement {
|
|
5
|
+
connectedCallback() {
|
|
6
|
+
if (this.dataset.started === "true") return
|
|
7
|
+
|
|
8
|
+
this.dataset.started = "true"
|
|
9
|
+
this.enqueue()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Several observables changing in one commit produce several notifications
|
|
13
|
+
// for the same batch and revision. Merging them in a microtask turns those
|
|
14
|
+
// into a single request.
|
|
15
|
+
enqueue() {
|
|
16
|
+
const batch = this.dataset.batch
|
|
17
|
+
const revision = this.dataset.revision
|
|
18
|
+
const source = this.dataset.source
|
|
19
|
+
const scope = this.closest("[id]")?.id
|
|
20
|
+
if (!batch || !revision || !source || !scope) return this.remove()
|
|
21
|
+
|
|
22
|
+
// Two actor scopes may reuse a batch name on one page. Keying by scope
|
|
23
|
+
// keeps their requests from merging or cancelling each other.
|
|
24
|
+
const group = `${scope}:${batch}`
|
|
25
|
+
const key = `${group}:${revision}`
|
|
26
|
+
const pending = pendingBatches.get(key)
|
|
27
|
+
if (pending) {
|
|
28
|
+
pending.sources.add(source)
|
|
29
|
+
this.remove()
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const merged = { sources: new Set([ source ]) }
|
|
34
|
+
pendingBatches.set(key, merged)
|
|
35
|
+
queueMicrotask(() => {
|
|
36
|
+
pendingBatches.delete(key)
|
|
37
|
+
requestBatch(group, batch, merged.sources)
|
|
38
|
+
})
|
|
39
|
+
this.remove()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function requestBatch(group, batch, sources) {
|
|
44
|
+
const previous = activeBatches.get(group)
|
|
45
|
+
previous?.abort()
|
|
46
|
+
|
|
47
|
+
const controller = new AbortController()
|
|
48
|
+
activeBatches.set(group, controller)
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const url = mergedUrl(sources)
|
|
52
|
+
if (!url) return
|
|
53
|
+
|
|
54
|
+
const response = await fetch(url, {
|
|
55
|
+
credentials: "same-origin",
|
|
56
|
+
headers: { Accept: "application/json" },
|
|
57
|
+
redirect: "error",
|
|
58
|
+
signal: controller.signal
|
|
59
|
+
})
|
|
60
|
+
if (!response.ok) return dispatchBatchError(batch, `http_${response.status}`)
|
|
61
|
+
|
|
62
|
+
const body = await response.json()
|
|
63
|
+
if (!Array.isArray(body?.frames)) {
|
|
64
|
+
return dispatchBatchError(batch, "invalid_response")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
body.frames.forEach(applyFrame)
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error.name !== "AbortError") dispatchBatchError(batch, "request_failed")
|
|
70
|
+
} finally {
|
|
71
|
+
if (activeBatches.get(group) === controller) activeBatches.delete(group)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Every notification for one batch and revision carries the same endpoint and
|
|
76
|
+
// differs only by which components changed, so the union of their tokens is the
|
|
77
|
+
// complete set to render.
|
|
78
|
+
function mergedUrl(sources) {
|
|
79
|
+
const urls = [ ...sources ].map((source) => new URL(source, window.location.href))
|
|
80
|
+
const first = urls[0]
|
|
81
|
+
if (!first || first.origin !== window.location.origin) return
|
|
82
|
+
|
|
83
|
+
const tokens = new Set()
|
|
84
|
+
urls.forEach((url) => {
|
|
85
|
+
url.searchParams.getAll("tokens[]").forEach((token) => tokens.add(token))
|
|
86
|
+
})
|
|
87
|
+
first.searchParams.delete("tokens[]")
|
|
88
|
+
tokens.forEach((token) => first.searchParams.append("tokens[]", token))
|
|
89
|
+
return first
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function applyFrame(frame) {
|
|
93
|
+
const target = document.getElementById(frame?.target)
|
|
94
|
+
if (!target || !frame.html) return
|
|
95
|
+
if (!newerRevision(frame.revision, target.dataset.solidObjectsRevision)) return
|
|
96
|
+
|
|
97
|
+
const parsed = new DOMParser().parseFromString(frame.html, "text/html")
|
|
98
|
+
const replacement = parsed.getElementById(frame.target)
|
|
99
|
+
if (!replacement) return
|
|
100
|
+
|
|
101
|
+
const stream = document.createElement("turbo-stream")
|
|
102
|
+
stream.setAttribute("action", "replace")
|
|
103
|
+
if (frame.refresh_method === "morph") stream.setAttribute("method", "morph")
|
|
104
|
+
stream.setAttribute("target", frame.target)
|
|
105
|
+
|
|
106
|
+
const template = document.createElement("template")
|
|
107
|
+
template.content.append(document.importNode(replacement, true))
|
|
108
|
+
stream.append(template)
|
|
109
|
+
document.documentElement.append(stream)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function newerRevision(candidate, current) {
|
|
113
|
+
const candidateRevision = parseRevision(candidate)
|
|
114
|
+
const currentRevision = parseRevision(current)
|
|
115
|
+
if (!candidateRevision || !currentRevision) return false
|
|
116
|
+
|
|
117
|
+
return candidateRevision[0] > currentRevision[0] ||
|
|
118
|
+
(candidateRevision[0] === currentRevision[0] &&
|
|
119
|
+
candidateRevision[1] > currentRevision[1])
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseRevision(revision) {
|
|
123
|
+
if (!revision) return
|
|
124
|
+
|
|
125
|
+
const values = String(revision).split(":").map(Number)
|
|
126
|
+
if (
|
|
127
|
+
values.length !== 2 ||
|
|
128
|
+
values.some((value) => !Number.isSafeInteger(value) || value < 0)
|
|
129
|
+
) return
|
|
130
|
+
|
|
131
|
+
return values
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function dispatchBatchError(batch, reason) {
|
|
135
|
+
document.dispatchEvent(
|
|
136
|
+
new CustomEvent("solid-objects:batch-refresh-error", {
|
|
137
|
+
bubbles: true,
|
|
138
|
+
detail: { batch, reason }
|
|
139
|
+
})
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!customElements.get("solid-objects-batch-refresh")) {
|
|
144
|
+
customElements.define(
|
|
145
|
+
"solid-objects-batch-refresh",
|
|
146
|
+
SolidObjectsBatchRefreshElement
|
|
147
|
+
)
|
|
148
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const deliveredRevisions = new Map()
|
|
2
|
+
|
|
3
|
+
class SolidObjectsPayloadElement extends HTMLElement {
|
|
4
|
+
connectedCallback() {
|
|
5
|
+
if (this.dataset.started === "true") return
|
|
6
|
+
|
|
7
|
+
this.dataset.started = "true"
|
|
8
|
+
this.deliver()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
deliver() {
|
|
12
|
+
try {
|
|
13
|
+
const name = this.dataset.name
|
|
14
|
+
const revision = revisionFor(this)
|
|
15
|
+
const scope = this.closest("[id]")
|
|
16
|
+
if (!name || !revision || !scope) return
|
|
17
|
+
|
|
18
|
+
const key = `${scope.id}:${name}`
|
|
19
|
+
if (!newerRevision(revision, deliveredRevisions.get(key))) return
|
|
20
|
+
|
|
21
|
+
const payload = JSON.parse(this.textContent)
|
|
22
|
+
deliveredRevisions.set(key, revision)
|
|
23
|
+
scope.dispatchEvent(
|
|
24
|
+
new CustomEvent("solid-objects:payload", {
|
|
25
|
+
bubbles: true,
|
|
26
|
+
detail: {
|
|
27
|
+
name,
|
|
28
|
+
instanceId: revision[0],
|
|
29
|
+
revision: revision[1],
|
|
30
|
+
payload
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
)
|
|
34
|
+
} catch {
|
|
35
|
+
this.dispatchEvent(
|
|
36
|
+
new CustomEvent("solid-objects:payload-error", {
|
|
37
|
+
bubbles: true,
|
|
38
|
+
detail: { reason: "invalid_payload" }
|
|
39
|
+
})
|
|
40
|
+
)
|
|
41
|
+
} finally {
|
|
42
|
+
this.remove()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function newerRevision(candidate, current) {
|
|
48
|
+
if (!current) return true
|
|
49
|
+
|
|
50
|
+
return candidate[0] > current[0] ||
|
|
51
|
+
(candidate[0] === current[0] && candidate[1] > current[1])
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function revisionFor(element) {
|
|
55
|
+
const revision = element.dataset.revision
|
|
56
|
+
if (!revision) return
|
|
57
|
+
|
|
58
|
+
const values = revision.split(":").map(Number)
|
|
59
|
+
if (
|
|
60
|
+
values.length !== 2 ||
|
|
61
|
+
values.some((value) => !Number.isSafeInteger(value) || value < 0)
|
|
62
|
+
) return
|
|
63
|
+
|
|
64
|
+
return values
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!customElements.get("solid-objects-payload")) {
|
|
68
|
+
customElements.define("solid-objects-payload", SolidObjectsPayloadElement)
|
|
69
|
+
}
|
|
@@ -6,13 +6,97 @@ module SolidObjects
|
|
|
6
6
|
class ComponentsController < ActionController::Base
|
|
7
7
|
protect_from_forgery with: :exception
|
|
8
8
|
|
|
9
|
+
BATCH_LIMIT = 50
|
|
10
|
+
|
|
9
11
|
# @rbs () -> void
|
|
10
12
|
def show
|
|
11
13
|
SolidObjects.instrument(:"component.refreshed") { |payload| refresh(payload) }
|
|
12
14
|
end
|
|
13
15
|
|
|
16
|
+
# @rbs () -> void
|
|
17
|
+
def batch
|
|
18
|
+
SolidObjects.instrument(:"component.batch_refreshed") { |payload| refresh_batch(payload) }
|
|
19
|
+
end
|
|
20
|
+
|
|
14
21
|
private
|
|
15
22
|
|
|
23
|
+
# @rbs (Hash[Symbol, untyped]) -> void
|
|
24
|
+
def refresh_batch(payload)
|
|
25
|
+
tokens = Array(params.require(:tokens))
|
|
26
|
+
raise ActionController::ParameterMissing, :tokens if tokens.empty?
|
|
27
|
+
raise ArgumentError if tokens.length > BATCH_LIMIT
|
|
28
|
+
|
|
29
|
+
registrations = tokens.map { |token| ComponentRegistration.from_token(token) }
|
|
30
|
+
validate_single_batch!(registrations)
|
|
31
|
+
requested_revision = requested_revision_key
|
|
32
|
+
snapshot = ActorSnapshot.new(registrations.first.reference)
|
|
33
|
+
payload.merge!(
|
|
34
|
+
actor_type: snapshot.reference.actor_type,
|
|
35
|
+
actor_id: snapshot.reference.actor_id,
|
|
36
|
+
batch: registrations.first.batch,
|
|
37
|
+
components: registrations.map(&:component_name),
|
|
38
|
+
instance_id: snapshot.instance_id,
|
|
39
|
+
revision: snapshot.revision
|
|
40
|
+
)
|
|
41
|
+
if newer_than_snapshot?(requested_revision, snapshot)
|
|
42
|
+
payload[:outcome] = "conflict"
|
|
43
|
+
return head :conflict
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
authorization_context = SolidObjects
|
|
47
|
+
.configuration
|
|
48
|
+
.component_authorization_context
|
|
49
|
+
.call(controller: self)
|
|
50
|
+
frames = registrations.map do |registration|
|
|
51
|
+
rendered = ComponentRenderer.new(
|
|
52
|
+
snapshot:,
|
|
53
|
+
registration:,
|
|
54
|
+
view_context: component_view_context,
|
|
55
|
+
authorization_context:
|
|
56
|
+
).call
|
|
57
|
+
{
|
|
58
|
+
"target" => registration.dom_id,
|
|
59
|
+
"revision" => "#{snapshot.instance_id}:#{snapshot.revision}",
|
|
60
|
+
"refresh_method" => registration.refresh_method,
|
|
61
|
+
"html" => component_frame(registration, snapshot, rendered)
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
response.headers["Cache-Control"] = "private, no-store"
|
|
65
|
+
payload[:outcome] = "rendered"
|
|
66
|
+
render json: {
|
|
67
|
+
"actor_type" => snapshot.reference.actor_type,
|
|
68
|
+
"actor_id" => snapshot.reference.actor_id,
|
|
69
|
+
"batch" => registrations.first.batch,
|
|
70
|
+
"instance_id" => snapshot.instance_id,
|
|
71
|
+
"revision" => snapshot.revision,
|
|
72
|
+
"frames" => frames
|
|
73
|
+
}
|
|
74
|
+
rescue Unauthorized
|
|
75
|
+
payload[:outcome] = "unauthorized"
|
|
76
|
+
head :forbidden
|
|
77
|
+
rescue UnknownComponent
|
|
78
|
+
payload[:outcome] = "unknown_component"
|
|
79
|
+
head :not_found
|
|
80
|
+
rescue ActionController::ParameterMissing,
|
|
81
|
+
ArgumentError,
|
|
82
|
+
InvalidComponentToken
|
|
83
|
+
payload[:outcome] = "invalid_token"
|
|
84
|
+
head :bad_request
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @rbs (Array[ComponentRegistration]) -> void
|
|
88
|
+
def validate_single_batch!(registrations)
|
|
89
|
+
first = registrations.first
|
|
90
|
+
raise ArgumentError unless first.batch
|
|
91
|
+
return if registrations.all? do |registration|
|
|
92
|
+
registration.batch == first.batch &&
|
|
93
|
+
registration.reference.actor_type == first.reference.actor_type &&
|
|
94
|
+
registration.reference.actor_id == first.reference.actor_id
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
raise ArgumentError
|
|
98
|
+
end
|
|
99
|
+
|
|
16
100
|
# @rbs (Hash[Symbol, untyped]) -> void
|
|
17
101
|
def refresh(payload)
|
|
18
102
|
registration = ComponentRegistration.from_token(
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
module ActorHelper
|
|
5
|
-
# @rbs (Reference, ?authorization_context: untyped) { (ActorView) -> untyped } -> untyped
|
|
6
|
-
def solid_object(reference, authorization_context: self, &block)
|
|
5
|
+
# @rbs (Reference, ?authorization_context: untyped, ?payloads: untyped) { (ActorView) -> untyped } -> untyped
|
|
6
|
+
def solid_object(reference, authorization_context: self, payloads: nil, &block)
|
|
7
|
+
payload_names = Array(payloads).map(&:to_s).uniq.presence
|
|
7
8
|
actor = ActorView.new(
|
|
8
9
|
reference:,
|
|
9
10
|
view_context: self,
|
|
@@ -13,7 +14,8 @@ module SolidObjects
|
|
|
13
14
|
subscription_data = {
|
|
14
15
|
token: StreamToken.generate(
|
|
15
16
|
reference,
|
|
16
|
-
observables: actor.scalar_observable_names
|
|
17
|
+
observables: actor.scalar_observable_names,
|
|
18
|
+
payloads: payload_names
|
|
17
19
|
)
|
|
18
20
|
}
|
|
19
21
|
if actor.component_tokens.any?
|
|
@@ -30,10 +32,26 @@ module SolidObjects
|
|
|
30
32
|
data: { turbo_track: "reload" }
|
|
31
33
|
)
|
|
32
34
|
end
|
|
35
|
+
batch_client = if actor.batched_components?
|
|
36
|
+
javascript_include_tag(
|
|
37
|
+
"solid_objects/component_batch_refresh",
|
|
38
|
+
type: "module",
|
|
39
|
+
data: { turbo_track: "reload" }
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
payload_client = if payload_names
|
|
43
|
+
javascript_include_tag(
|
|
44
|
+
"solid_objects/state_payload",
|
|
45
|
+
type: "module",
|
|
46
|
+
data: { turbo_track: "reload" }
|
|
47
|
+
)
|
|
48
|
+
end
|
|
33
49
|
|
|
34
50
|
content_tag(
|
|
35
51
|
:div,
|
|
36
|
-
safe_join(
|
|
52
|
+
safe_join(
|
|
53
|
+
[ refresh_client, batch_client, payload_client, subscription, content ].compact
|
|
54
|
+
),
|
|
37
55
|
id: DomIdentity.scope(reference)
|
|
38
56
|
)
|
|
39
57
|
end
|
data/config/routes.rb
CHANGED
data/docs/realtime.md
CHANGED
|
@@ -110,6 +110,167 @@ applications discover the namespaced engine asset. Applications created with
|
|
|
110
110
|
explicitly serve the module. Turbo's normal morph rules still apply; use
|
|
111
111
|
`data-turbo-permanent` for elements that must never be changed.
|
|
112
112
|
|
|
113
|
+
## Batched component refreshes
|
|
114
|
+
|
|
115
|
+
A component refresh costs one browser request. When one actor mutation changes
|
|
116
|
+
several components, the page pays one request per component. Adding `batch:`
|
|
117
|
+
groups them so a revision costs one request no matter how many components in the
|
|
118
|
+
group changed:
|
|
119
|
+
|
|
120
|
+
```erb
|
|
121
|
+
<%= actor.component :player, key: 1,
|
|
122
|
+
observes: :player_one, batch: :playmat, refresh: :morph %>
|
|
123
|
+
|
|
124
|
+
<%= actor.component :player_controls, key: 1,
|
|
125
|
+
observes: :player_one_controls, batch: :playmat, refresh: :morph %>
|
|
126
|
+
|
|
127
|
+
<%= actor.component :library_search, key: 1,
|
|
128
|
+
observes: :library, batch: :playmat, refresh: :morph %>
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Before, one mutation touching all three observables produced three requests:
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
commit -> 3 invalidations -> 3 refresh elements -> 3 GET /solid_objects/components
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
After, the three notifications coalesce in the browser into one request:
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
commit -> 3 invalidations -> 1 GET /solid_objects/components/batch -> 3 frames
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### The batch endpoint
|
|
144
|
+
|
|
145
|
+
`GET /solid_objects/components/batch` takes the signed `tokens[]` of the
|
|
146
|
+
components to render plus the `instance_id` and `revision` the browser holds. It
|
|
147
|
+
returns HTML frames inside a JSON envelope:
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
{
|
|
151
|
+
"actor_type": "playmat_room",
|
|
152
|
+
"actor_id": "table-1",
|
|
153
|
+
"batch": "playmat",
|
|
154
|
+
"instance_id": 12,
|
|
155
|
+
"revision": 48,
|
|
156
|
+
"frames": [
|
|
157
|
+
{
|
|
158
|
+
"target": "solid-objects-component-...",
|
|
159
|
+
"revision": "12:48",
|
|
160
|
+
"refresh_method": "morph",
|
|
161
|
+
"html": "<turbo-frame id=\"...\" data-solid-objects-revision=\"12:48\">...</turbo-frame>"
|
|
162
|
+
}
|
|
163
|
+
]
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
**Why frames inside JSON rather than one HTML document or pure JSON state.** HTML
|
|
168
|
+
alone would force the browser to pick frames out of an undocumented document.
|
|
169
|
+
Pure JSON would mean a second renderer and would give up ERB and Turbo morph. A
|
|
170
|
+
JSON envelope of frame descriptors keeps `ComponentRenderer` and Turbo exactly as
|
|
171
|
+
they are while giving the client a documented contract with per-frame revisions.
|
|
172
|
+
|
|
173
|
+
### What the protocol guarantees
|
|
174
|
+
|
|
175
|
+
Only components whose dependencies changed are requested; the rest are never
|
|
176
|
+
named in the batch. Duplicate notifications for the same batch and revision merge
|
|
177
|
+
into one request, and a superseded request for the same batch is aborted. Each
|
|
178
|
+
frame carries its own revision and cannot overwrite a target that already holds a
|
|
179
|
+
newer one. Authorization is unchanged: every component in the batch passes the
|
|
180
|
+
same `authorize_query` boundary an individual refresh uses, and the batch name is
|
|
181
|
+
signed into the component token, so a browser cannot invent or widen a group. A
|
|
182
|
+
batch mixing actors or groups is rejected.
|
|
183
|
+
|
|
184
|
+
Components without `batch:` keep issuing their own request, and a scope can mix
|
|
185
|
+
batched and unbatched components freely.
|
|
186
|
+
|
|
187
|
+
## Personalized state payloads
|
|
188
|
+
|
|
189
|
+
Reactive ERB components cost one browser request per changed component. When a
|
|
190
|
+
single actor mutation changes several components, an application pays several
|
|
191
|
+
round trips for one logical update. A payload broadcast collapses that into one
|
|
192
|
+
message on the stream the page already has open.
|
|
193
|
+
|
|
194
|
+
Declare the payload on the actor. The block receives the actor and the
|
|
195
|
+
subscriber's authorization context, and it runs **once per subscriber**, so two
|
|
196
|
+
sessions watching the same actor never see each other's private state:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
class PlaymatRoom < SolidObjects::Actor
|
|
200
|
+
actor_type "playmat_room"
|
|
201
|
+
|
|
202
|
+
attribute :hands, default: -> { {} }
|
|
203
|
+
attribute :turn, default: 1
|
|
204
|
+
|
|
205
|
+
observable :turn
|
|
206
|
+
|
|
207
|
+
broadcast_payload :playmat_state do |room, authorization_context|
|
|
208
|
+
{
|
|
209
|
+
"turn" => room.turn,
|
|
210
|
+
"hand" => room.hands.fetch(authorization_context.session_id, [])
|
|
211
|
+
}
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Subscribe the scope to it:
|
|
217
|
+
|
|
218
|
+
```erb
|
|
219
|
+
<%= solid_object room, payloads: :playmat_state do |actor| %>
|
|
220
|
+
<div data-playmat></div>
|
|
221
|
+
<% end %>
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Handle it with any JavaScript. The gem dispatches a DOM event and requires no
|
|
225
|
+
framework:
|
|
226
|
+
|
|
227
|
+
```javascript
|
|
228
|
+
document.addEventListener("solid-objects:payload", (event) => {
|
|
229
|
+
const { name, revision, payload } = event.detail
|
|
230
|
+
if (name !== "playmat_state") return
|
|
231
|
+
|
|
232
|
+
renderPlaymat(payload)
|
|
233
|
+
})
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### What the protocol guarantees
|
|
237
|
+
|
|
238
|
+
The payload travels as a Turbo Stream element on the existing actor stream, so
|
|
239
|
+
applications do not run a second WebSocket system. Each message carries the
|
|
240
|
+
actor identity plus the `instance_id` and monotonic `state_revision` that fence
|
|
241
|
+
component refreshes, and both the channel and the browser drop a payload that
|
|
242
|
+
is not newer than the last one delivered for that scope and name. A reconnecting
|
|
243
|
+
client receives the current payload on subscribe.
|
|
244
|
+
|
|
245
|
+
Authorization is the same `authorize_query` boundary that components use, called
|
|
246
|
+
with the payload name and the subscriber's Cable connection. A subscriber that
|
|
247
|
+
fails the check is skipped rather than served a partial payload, and the payload
|
|
248
|
+
name is signed into the stream token, so a browser cannot ask for a payload the
|
|
249
|
+
server did not offer.
|
|
250
|
+
|
|
251
|
+
Payload blocks read committed actor state through the same snapshot components
|
|
252
|
+
use. They cannot write application records, and the return value must be a JSON
|
|
253
|
+
object or array so the wire format stays inspectable.
|
|
254
|
+
|
|
255
|
+
### mtg-playmat before and after
|
|
256
|
+
|
|
257
|
+
Before, one mutation that touched three observables produced three refresh
|
|
258
|
+
elements and three HTTP requests:
|
|
259
|
+
|
|
260
|
+
```
|
|
261
|
+
commit -> 3 Action Cable messages -> 3 GET /solid_objects/components -> 3 renders
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
After, the same mutation delivers one personalized payload and the page renders
|
|
265
|
+
once:
|
|
266
|
+
|
|
267
|
+
```
|
|
268
|
+
commit -> 1 Action Cable message -> 0 HTTP requests -> 1 render
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Components remain the default. An actor with no `broadcast_payload` and a scope
|
|
272
|
+
with no `payloads:` option behave exactly as before.
|
|
273
|
+
|
|
113
274
|
## Authorization
|
|
114
275
|
|
|
115
276
|
The HTML contains a signed actor identity token. Signing prevents modification;
|
data/lib/solid_objects/actor.rb
CHANGED
|
@@ -57,6 +57,13 @@ module SolidObjects
|
|
|
57
57
|
definition.add_observable(name, block)
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# @rbs (Symbol | String) { (untyped, untyped) -> untyped } -> ActorDefinition::Handler
|
|
61
|
+
def broadcast_payload(name, &block)
|
|
62
|
+
raise InvalidActor, "payload broadcasts require a block" unless block
|
|
63
|
+
|
|
64
|
+
definition.add_payload_broadcast(name, block)
|
|
65
|
+
end
|
|
66
|
+
|
|
60
67
|
# @rbs (?Integer) -> Integer
|
|
61
68
|
def state_version(version = nil)
|
|
62
69
|
definition.set_state_version(version) if version
|
|
@@ -19,7 +19,9 @@ module SolidObjects
|
|
|
19
19
|
|
|
20
20
|
@reference = Reference.new(actor_type:, actor_id:)
|
|
21
21
|
@scalar_observables = identity["observables"]
|
|
22
|
+
@payload_names = identity["payloads"]
|
|
22
23
|
validate_scalar_observables!
|
|
24
|
+
validate_payload_names!
|
|
23
25
|
@component_subscriptions = ComponentSubscriptions.parse(
|
|
24
26
|
params["components"],
|
|
25
27
|
reference:
|
|
@@ -36,6 +38,7 @@ module SolidObjects
|
|
|
36
38
|
)
|
|
37
39
|
end
|
|
38
40
|
refresh_outdated_components(snapshot)
|
|
41
|
+
transmit_state_payloads(snapshot)
|
|
39
42
|
rescue KeyError,
|
|
40
43
|
JSON::ParserError,
|
|
41
44
|
InvalidStreamToken,
|
|
@@ -46,14 +49,20 @@ module SolidObjects
|
|
|
46
49
|
|
|
47
50
|
private
|
|
48
51
|
|
|
49
|
-
attr_reader :reference,
|
|
52
|
+
attr_reader :reference,
|
|
53
|
+
:component_subscriptions,
|
|
54
|
+
:scalar_observables,
|
|
55
|
+
:payload_names
|
|
50
56
|
|
|
51
57
|
# @rbs (String) -> void
|
|
52
58
|
def receive_broadcast(stream)
|
|
53
59
|
invalidation = TurboStreamRenderer.invalidation(stream)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
revision_only = invalidation &&
|
|
61
|
+
invalidation.fetch("observable_name") == PayloadBroadcast::REVISION_OBSERVABLE
|
|
62
|
+
if !revision_only &&
|
|
63
|
+
(!invalidation ||
|
|
64
|
+
scalar_observables.nil? ||
|
|
65
|
+
scalar_observables.include?(invalidation.fetch("observable_name")))
|
|
57
66
|
transmit stream
|
|
58
67
|
end
|
|
59
68
|
return unless invalidation
|
|
@@ -61,6 +70,48 @@ module SolidObjects
|
|
|
61
70
|
component_subscriptions
|
|
62
71
|
.refreshes_for(invalidation)
|
|
63
72
|
.each { |refresh| transmit refresh }
|
|
73
|
+
transmit_state_payloads(ActorSnapshot.new(reference))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @rbs (ActorSnapshot) -> void
|
|
77
|
+
def transmit_state_payloads(snapshot)
|
|
78
|
+
return if payload_names.nil? || payload_names.empty?
|
|
79
|
+
return unless newer_payload_revision?(snapshot)
|
|
80
|
+
|
|
81
|
+
payload_names.each do |name|
|
|
82
|
+
payload = PayloadBroadcast.new(
|
|
83
|
+
snapshot:,
|
|
84
|
+
name:,
|
|
85
|
+
authorization_context: connection
|
|
86
|
+
).call
|
|
87
|
+
transmit TurboStreamRenderer.state_payload(payload)
|
|
88
|
+
rescue Unauthorized
|
|
89
|
+
next
|
|
90
|
+
end
|
|
91
|
+
@payload_revision = [ snapshot.instance_id, snapshot.revision ]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @rbs (ActorSnapshot) -> bool
|
|
95
|
+
def newer_payload_revision?(snapshot)
|
|
96
|
+
current = @payload_revision
|
|
97
|
+
return true unless current
|
|
98
|
+
|
|
99
|
+
(current <=> [ snapshot.instance_id, snapshot.revision ]) == -1
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @rbs () -> void
|
|
103
|
+
def validate_payload_names!
|
|
104
|
+
return unless payload_names
|
|
105
|
+
|
|
106
|
+
broadcasts = SolidObjects
|
|
107
|
+
.registry
|
|
108
|
+
.fetch(reference.actor_type)
|
|
109
|
+
.definition
|
|
110
|
+
.payload_broadcasts
|
|
111
|
+
unknown = payload_names.find { |name| !broadcasts.key?(name.to_sym) }
|
|
112
|
+
return unless unknown
|
|
113
|
+
|
|
114
|
+
raise InvalidStreamToken, "unknown payload broadcast #{unknown.inspect}"
|
|
64
115
|
end
|
|
65
116
|
|
|
66
117
|
# @rbs (ActorSnapshot) -> void
|