solid_objects 0.4.3 → 0.5.1
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 +33 -0
- data/README.md +54 -7
- data/app/assets/javascripts/solid_objects/component_refresh.js +124 -0
- data/app/controllers/solid_objects/components_controller.rb +31 -9
- data/app/helpers/solid_objects/actor_helper.rb +8 -1
- data/docs/adr/0009-realtime-updates.md +18 -1
- data/docs/architecture.md +34 -18
- data/docs/authorization.md +24 -0
- data/docs/correctness.md +27 -5
- data/docs/operations.md +20 -2
- data/docs/realtime.md +78 -13
- data/docs/roadmap.md +6 -5
- data/examples/application/app/views/chat_rooms/show.html.erb +3 -1
- data/lib/solid_objects/actor_view.rb +40 -13
- data/lib/solid_objects/component_registration.rb +61 -16
- data/lib/solid_objects/component_renderer.rb +14 -17
- data/lib/solid_objects/component_subscriptions.rb +7 -7
- data/lib/solid_objects/component_token.rb +70 -2
- data/lib/solid_objects/database_adapters/sqlite.rb +43 -5
- data/lib/solid_objects/doctor.rb +46 -8
- data/lib/solid_objects/dom_identity.rb +12 -3
- data/lib/solid_objects/engine.rb +7 -0
- data/lib/solid_objects/synchronous_invocation.rb +16 -1
- data/lib/solid_objects/turbo_stream_renderer.rb +14 -5
- data/lib/solid_objects/version.rb +1 -1
- data/sig/generated/controllers/solid_objects/components_controller.rbs +6 -0
- data/sig/generated/lib/solid_objects/actor_view.rbs +10 -4
- data/sig/generated/lib/solid_objects/component_registration.rbs +34 -10
- data/sig/generated/lib/solid_objects/component_renderer.rbs +4 -8
- data/sig/generated/lib/solid_objects/component_token.rbs +24 -2
- data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +9 -0
- data/sig/generated/lib/solid_objects/doctor.rbs +12 -0
- data/sig/generated/lib/solid_objects/dom_identity.rbs +5 -2
- data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +8 -0
- data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +3 -0
- metadata +3 -2
data/docs/realtime.md
CHANGED
|
@@ -28,11 +28,37 @@ never influence partial resolution. The older
|
|
|
28
28
|
`actor.component(:summary, partial: "server/chosen/path")` form remains
|
|
29
29
|
available for initial-only static rendering.
|
|
30
30
|
|
|
31
|
-
The partial receives
|
|
31
|
+
The partial receives these built-in component locals:
|
|
32
32
|
|
|
33
33
|
- `actor`, which exposes the declared observables as deeply frozen ordinary
|
|
34
34
|
Ruby values plus `actor_id` and `reference`; and
|
|
35
35
|
- `authorization_context`, the context for this initial render or refresh.
|
|
36
|
+
- `component_key`, the signed string or integer key, or `nil` for an unkeyed
|
|
37
|
+
component.
|
|
38
|
+
|
|
39
|
+
Applications can declare additional JSON-compatible locals. They are
|
|
40
|
+
normalized, signed into the component token, deeply frozen, and supplied on
|
|
41
|
+
both the initial render and every refresh:
|
|
42
|
+
|
|
43
|
+
```erb
|
|
44
|
+
<% @players.each do |player| %>
|
|
45
|
+
<%= actor.component :player,
|
|
46
|
+
key: player.id,
|
|
47
|
+
observes: %i[players life_totals],
|
|
48
|
+
locals: { player_id: player.id } %>
|
|
49
|
+
<% end %>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
This resolves every instance to the same `_player.html.erb` partial while
|
|
53
|
+
giving each one a distinct opaque DOM target. The `(component name, key)` pair
|
|
54
|
+
must be unique within one `solid_object` scope. An unkeyed component retains
|
|
55
|
+
the existing target and uniqueness behavior.
|
|
56
|
+
|
|
57
|
+
Local names must be valid Ruby local identifiers. `actor`,
|
|
58
|
+
`authorization_context`, and `component_key` are reserved. Local values must
|
|
59
|
+
use the same safe JSON value set as actor messages. Tokens are limited to
|
|
60
|
+
16 KiB and one subscription accepts at most 50 components, so locals should be
|
|
61
|
+
small identifiers or rendering options rather than copied actor state.
|
|
36
62
|
|
|
37
63
|
`actor.state` is unavailable in reactive components. Reading an observable not
|
|
38
64
|
listed in `observes:` raises `UnknownComponentDependency`. This keeps
|
|
@@ -51,6 +77,39 @@ Arrays, hashes, loops, conditionals, nested markup, and host helper output are
|
|
|
51
77
|
normal ERB. Escaping remains Action View's responsibility; Solid Objects never
|
|
52
78
|
marks actor strings as HTML safe.
|
|
53
79
|
|
|
80
|
+
## Replace and morph refreshes
|
|
81
|
+
|
|
82
|
+
Reactive components use `refresh: :replace` by default. The existing path
|
|
83
|
+
replaces the target with a Turbo Frame whose signed URL performs the authorized
|
|
84
|
+
request-time render.
|
|
85
|
+
|
|
86
|
+
Use `refresh: :morph` when preserving unchanged DOM nodes matters:
|
|
87
|
+
|
|
88
|
+
```erb
|
|
89
|
+
<%= actor.component :battlefield,
|
|
90
|
+
key: player.id,
|
|
91
|
+
observes: %i[battlefields zone_counts],
|
|
92
|
+
locals: { player_id: player.id },
|
|
93
|
+
refresh: :morph %>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Morph invalidations append a short-lived gem-owned browser element to the
|
|
97
|
+
actor scope. It fetches the same signed component endpoint with normal
|
|
98
|
+
same-origin cookies, aborts an older request for the same keyed target, and
|
|
99
|
+
converts the authorized response into Turbo's scoped
|
|
100
|
+
`replace method="morph"`. Before applying it, the browser compares the
|
|
101
|
+
response's `(instance_id, state_revision)` with the current target. An older
|
|
102
|
+
response cannot overwrite newer HTML.
|
|
103
|
+
|
|
104
|
+
The engine exposes the `solid_objects/component_refresh` module through the
|
|
105
|
+
host asset pipeline and `solid_object` includes it only when the scope contains
|
|
106
|
+
a morph component. No host Stimulus controller, custom channel, custom stream
|
|
107
|
+
action, or polling loop is required. Default Propshaft and Sprockets
|
|
108
|
+
applications discover the namespaced engine asset. Applications created with
|
|
109
|
+
`--skip-asset-pipeline` should keep the default replace strategy unless they
|
|
110
|
+
explicitly serve the module. Turbo's normal morph rules still apply; use
|
|
111
|
+
`data-turbo-permanent` for elements that must never be changed.
|
|
112
|
+
|
|
54
113
|
## Authorization
|
|
55
114
|
|
|
56
115
|
The HTML contains a signed actor identity token. Signing prevents modification;
|
|
@@ -61,9 +120,10 @@ approval.
|
|
|
61
120
|
Initial scalar and component reads call `authorize_query` with the context
|
|
62
121
|
passed to `solid_object`. The refresh controller resolves a new request context
|
|
63
122
|
through `component_authorization_context`, then calls `authorize_query` again
|
|
64
|
-
for the component name and every declared dependency.
|
|
65
|
-
|
|
66
|
-
|
|
123
|
+
for the component name and every declared dependency. Keyed registrations pass
|
|
124
|
+
their `component_key` plus all declared locals as `arguments` at both
|
|
125
|
+
boundaries. The default resolver supplies the engine controller; applications
|
|
126
|
+
commonly resolve it to `Current.user`:
|
|
67
127
|
|
|
68
128
|
```ruby
|
|
69
129
|
configuration.component_authorization_context = ->(controller:) { Current.user }
|
|
@@ -77,8 +137,10 @@ The three contexts are intentionally different:
|
|
|
77
137
|
| Action Cable subscription | The authenticated Cable connection |
|
|
78
138
|
| Component refresh | Value returned by `component_authorization_context` for the engine controller request |
|
|
79
139
|
|
|
80
|
-
Do not substitute a signed token for any of them.
|
|
81
|
-
|
|
140
|
+
Do not substitute a signed token for any of them. Keys and locals are visible
|
|
141
|
+
to the browser and signed for integrity, not encrypted or authorized. Never
|
|
142
|
+
authorize solely from actor ID, token possession, stream name, component name,
|
|
143
|
+
component key, locals, or DOM ID.
|
|
82
144
|
|
|
83
145
|
## Broadcast durability
|
|
84
146
|
|
|
@@ -96,10 +158,11 @@ authorized browser requests affected components through the engine endpoint
|
|
|
96
158
|
with its normal cookies. Responses are `private, no-store`.
|
|
97
159
|
|
|
98
160
|
Several changed dependencies from one message sequence produce one logical
|
|
99
|
-
refresh for
|
|
100
|
-
newer invalidation arrives while a
|
|
101
|
-
frame replaces the old frame element; the detached older
|
|
102
|
-
current target.
|
|
161
|
+
refresh for each keyed component registration. An unrelated observable does
|
|
162
|
+
not refresh it. If a newer invalidation arrives while a replace request is in
|
|
163
|
+
flight, the new frame replaces the old frame element; the detached older
|
|
164
|
+
response has no current target. Morph requests are coalesced per target with an
|
|
165
|
+
`AbortController` and perform a final client-side revision comparison.
|
|
103
166
|
|
|
104
167
|
If Cable delivery is lost, reconnecting `ActorChannel` transmits replacements
|
|
105
168
|
from current actor state. It compares the signed component revision with the
|
|
@@ -111,8 +174,8 @@ truth.
|
|
|
111
174
|
|
|
112
175
|
The component endpoint rejects a requested revision newer than the committed
|
|
113
176
|
snapshot. This is a final server-side guard; browser safety primarily comes
|
|
114
|
-
from monotonic channel filtering
|
|
115
|
-
|
|
177
|
+
from monotonic channel filtering plus replace-frame detachment or morph
|
|
178
|
+
response revision fencing.
|
|
116
179
|
|
|
117
180
|
## Cost model
|
|
118
181
|
|
|
@@ -120,7 +183,9 @@ The durable row cost is unchanged: one broadcast row per changed observable,
|
|
|
120
183
|
containing its JSON value and the message/instance references needed to derive
|
|
121
184
|
invalidation metadata. No rendered document is stored. Each affected component
|
|
122
185
|
adds one authorized GET and one partial render per non-coalesced state
|
|
123
|
-
revision.
|
|
186
|
+
revision. A repeated keyed component adds one GET and render per key. Signed
|
|
187
|
+
locals increase page and Cable subscription bytes but do not create durable
|
|
188
|
+
rows. Scalar observables remain the cheaper path for one text value.
|
|
124
189
|
|
|
125
190
|
## Deployment
|
|
126
191
|
|
data/docs/roadmap.md
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
- Transactional effects with success/failure actor messages
|
|
16
16
|
- Actor-to-actor asynchronous outbox delivery
|
|
17
17
|
- One-shot and recurring reminders with `:latest` or `:all` catch-up
|
|
18
|
-
- Durable observable invalidations, scalar Turbo replacement,
|
|
19
|
-
|
|
18
|
+
- Durable observable invalidations, scalar Turbo replacement, keyed ERB
|
|
19
|
+
components, signed component locals, and authorized replace or morph refresh
|
|
20
20
|
- Reconciliation read APIs
|
|
21
21
|
- Installation doctor, authorization reference, fit guide, and legacy-state
|
|
22
22
|
migration cookbook
|
|
@@ -36,9 +36,10 @@
|
|
|
36
36
|
role or run periodic maintenance automatically.
|
|
37
37
|
- Wake-up strategy: in-process signaling plus durable polling and injection are
|
|
38
38
|
implemented; PostgreSQL `LISTEN/NOTIFY` and optional Redis adapters are not.
|
|
39
|
-
- Realtime: scalar and dependency-driven ERB component replacement
|
|
40
|
-
personalized refresh authorization, revision fencing, coalescing,
|
|
41
|
-
reconnect convergence are implemented; Turbo
|
|
39
|
+
- Realtime: scalar and dependency-driven keyed ERB component replacement or
|
|
40
|
+
morphing, personalized refresh authorization, revision fencing, coalescing,
|
|
41
|
+
and reconnect convergence are implemented; application-directed Turbo
|
|
42
|
+
append intents are not.
|
|
42
43
|
- Backpressure: mailbox/payload/state/result caps and fair yields exist;
|
|
43
44
|
distributed per-actor rate limits and global admission control do not.
|
|
44
45
|
- Administration: actor and dead-letter views plus policy hooks exist; richer
|
|
@@ -37,10 +37,20 @@ module SolidObjects
|
|
|
37
37
|
)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
# @rbs (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?) -> untyped
|
|
41
|
-
def component(
|
|
40
|
+
# @rbs (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?, ?key: untyped, ?locals: Hash[untyped, untyped], ?refresh: String | Symbol) -> untyped
|
|
41
|
+
def component(
|
|
42
|
+
name,
|
|
43
|
+
observes: nil,
|
|
44
|
+
partial: nil,
|
|
45
|
+
key: nil,
|
|
46
|
+
locals: {},
|
|
47
|
+
refresh: :replace
|
|
48
|
+
)
|
|
42
49
|
component_name = normalized_component_name(name)
|
|
43
|
-
|
|
50
|
+
unless observes
|
|
51
|
+
validate_static_options!(key:, locals:, refresh:)
|
|
52
|
+
return static_component(component_name, partial:)
|
|
53
|
+
end
|
|
44
54
|
if partial
|
|
45
55
|
raise ArgumentError,
|
|
46
56
|
"reactive components resolve partials by actor and component name"
|
|
@@ -48,19 +58,21 @@ module SolidObjects
|
|
|
48
58
|
|
|
49
59
|
dependencies = normalized_dependencies(observes)
|
|
50
60
|
validate_dependencies!(dependencies)
|
|
51
|
-
ensure_unique_component!(component_name)
|
|
52
61
|
refresh_path = component_path_resolver.call(view_context:)
|
|
53
62
|
registration = ComponentRegistration.issue(
|
|
54
63
|
reference:,
|
|
55
64
|
component_name:,
|
|
65
|
+
component_key: key,
|
|
56
66
|
dependencies:,
|
|
67
|
+
locals:,
|
|
68
|
+
refresh_method: refresh,
|
|
57
69
|
snapshot:,
|
|
58
70
|
refresh_path:
|
|
59
71
|
)
|
|
72
|
+
ensure_unique_component!(registration)
|
|
60
73
|
rendered = ComponentRenderer.new(
|
|
61
74
|
snapshot:,
|
|
62
|
-
|
|
63
|
-
dependencies:,
|
|
75
|
+
registration:,
|
|
64
76
|
view_context:,
|
|
65
77
|
authorization_context:
|
|
66
78
|
).call
|
|
@@ -68,9 +80,10 @@ module SolidObjects
|
|
|
68
80
|
view_context.content_tag(
|
|
69
81
|
:"turbo-frame",
|
|
70
82
|
rendered,
|
|
71
|
-
id:
|
|
83
|
+
id: registration.dom_id,
|
|
72
84
|
data: {
|
|
73
|
-
solid_objects_revision: "#{snapshot.instance_id}:#{snapshot.revision}"
|
|
85
|
+
solid_objects_revision: "#{snapshot.instance_id}:#{snapshot.revision}",
|
|
86
|
+
solid_objects_refresh: registration.refresh_method
|
|
74
87
|
}
|
|
75
88
|
)
|
|
76
89
|
end
|
|
@@ -85,6 +98,11 @@ module SolidObjects
|
|
|
85
98
|
observable_names.dup
|
|
86
99
|
end
|
|
87
100
|
|
|
101
|
+
# @rbs () -> bool
|
|
102
|
+
def morph_components?
|
|
103
|
+
component_registrations.any?(&:morph?)
|
|
104
|
+
end
|
|
105
|
+
|
|
88
106
|
# @rbs () -> State
|
|
89
107
|
def state
|
|
90
108
|
snapshot.actor.state
|
|
@@ -183,14 +201,23 @@ module SolidObjects
|
|
|
183
201
|
"unknown observable dependency #{unknown.inspect} for #{reference.actor_type}"
|
|
184
202
|
end
|
|
185
203
|
|
|
186
|
-
# @rbs (
|
|
187
|
-
def ensure_unique_component!(
|
|
188
|
-
return unless component_registrations.any? do |
|
|
189
|
-
|
|
204
|
+
# @rbs (ComponentRegistration) -> void
|
|
205
|
+
def ensure_unique_component!(registration)
|
|
206
|
+
return unless component_registrations.any? do |existing_registration|
|
|
207
|
+
existing_registration.dom_id == registration.dom_id
|
|
190
208
|
end
|
|
191
209
|
|
|
192
210
|
raise ArgumentError,
|
|
193
|
-
"component #{component_name.inspect}
|
|
211
|
+
"component #{registration.component_name.inspect} with key " \
|
|
212
|
+
"#{registration.component_key.inspect} is already rendered in this solid_object scope"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# @rbs (key: untyped, locals: Hash[untyped, untyped], refresh: String | Symbol) -> void
|
|
216
|
+
def validate_static_options!(key:, locals:, refresh:)
|
|
217
|
+
return if key.nil? && locals.empty? && refresh.to_s == "replace"
|
|
218
|
+
|
|
219
|
+
raise ArgumentError,
|
|
220
|
+
"key, locals, and refresh require an observable component"
|
|
194
221
|
end
|
|
195
222
|
|
|
196
223
|
# @rbs () -> Proc | ComponentPathResolver
|
|
@@ -6,7 +6,10 @@ module SolidObjects
|
|
|
6
6
|
class ComponentRegistration
|
|
7
7
|
# @rbs @reference: Reference
|
|
8
8
|
# @rbs @component_name: String
|
|
9
|
+
# @rbs @component_key: String | Integer?
|
|
9
10
|
# @rbs @dependencies: Array[String]
|
|
11
|
+
# @rbs @locals: Hash[String, untyped]
|
|
12
|
+
# @rbs @refresh_method: String
|
|
10
13
|
# @rbs @instance_id: Integer
|
|
11
14
|
# @rbs @revision: Integer
|
|
12
15
|
# @rbs @refresh_path: String
|
|
@@ -14,17 +17,23 @@ module SolidObjects
|
|
|
14
17
|
|
|
15
18
|
attr_reader :reference,
|
|
16
19
|
:component_name,
|
|
20
|
+
:component_key,
|
|
17
21
|
:dependencies,
|
|
22
|
+
:locals,
|
|
23
|
+
:refresh_method,
|
|
18
24
|
:instance_id,
|
|
19
25
|
:revision,
|
|
20
26
|
:refresh_path,
|
|
21
27
|
:token
|
|
22
28
|
|
|
23
|
-
# @rbs (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String, token: String) -> void
|
|
29
|
+
# @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
|
|
24
30
|
def initialize(
|
|
25
31
|
reference:,
|
|
26
32
|
component_name:,
|
|
33
|
+
component_key:,
|
|
27
34
|
dependencies:,
|
|
35
|
+
locals:,
|
|
36
|
+
refresh_method:,
|
|
28
37
|
instance_id:,
|
|
29
38
|
revision:,
|
|
30
39
|
refresh_path:,
|
|
@@ -32,7 +41,10 @@ module SolidObjects
|
|
|
32
41
|
)
|
|
33
42
|
@reference = reference
|
|
34
43
|
@component_name = component_name
|
|
44
|
+
@component_key = component_key
|
|
35
45
|
@dependencies = dependencies.freeze
|
|
46
|
+
@locals = Serialization.readonly_copy(locals)
|
|
47
|
+
@refresh_method = refresh_method
|
|
36
48
|
@instance_id = instance_id
|
|
37
49
|
@revision = revision
|
|
38
50
|
@refresh_path = refresh_path
|
|
@@ -40,30 +52,43 @@ module SolidObjects
|
|
|
40
52
|
end
|
|
41
53
|
|
|
42
54
|
class << self
|
|
43
|
-
# @rbs (reference: Reference, component_name: String, dependencies: Array[String], snapshot: ActorSnapshot, refresh_path: String) -> ComponentRegistration
|
|
44
|
-
def issue(
|
|
55
|
+
# @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
|
|
56
|
+
def issue(
|
|
57
|
+
reference:,
|
|
58
|
+
component_name:,
|
|
59
|
+
component_key:,
|
|
60
|
+
dependencies:,
|
|
61
|
+
locals:,
|
|
62
|
+
refresh_method:,
|
|
63
|
+
snapshot:,
|
|
64
|
+
refresh_path:
|
|
65
|
+
)
|
|
45
66
|
token = ComponentToken.generate(
|
|
46
67
|
reference:,
|
|
47
68
|
component_name:,
|
|
69
|
+
component_key:,
|
|
48
70
|
dependencies:,
|
|
71
|
+
locals:,
|
|
72
|
+
refresh_method:,
|
|
49
73
|
instance_id: snapshot.instance_id,
|
|
50
74
|
revision: snapshot.revision,
|
|
51
75
|
refresh_path:
|
|
52
76
|
)
|
|
53
|
-
|
|
54
|
-
reference:,
|
|
55
|
-
component_name:,
|
|
56
|
-
dependencies:,
|
|
57
|
-
instance_id: snapshot.instance_id,
|
|
58
|
-
revision: snapshot.revision,
|
|
59
|
-
refresh_path:,
|
|
60
|
-
token:
|
|
61
|
-
)
|
|
77
|
+
build(ComponentToken.verify(token), token:)
|
|
62
78
|
end
|
|
63
79
|
|
|
64
80
|
# @rbs (String) -> ComponentRegistration
|
|
65
81
|
def from_token(token)
|
|
66
82
|
payload = ComponentToken.verify(token)
|
|
83
|
+
build(payload, token:)
|
|
84
|
+
rescue UnknownActorType, UnknownComponentDependency => error
|
|
85
|
+
raise InvalidComponentToken, error.message
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# @rbs (Hash[String, untyped], token: String) -> ComponentRegistration
|
|
91
|
+
def build(payload, token:)
|
|
67
92
|
reference = Reference.new(
|
|
68
93
|
actor_type: payload.fetch("actor_type"),
|
|
69
94
|
actor_id: payload.fetch("actor_id")
|
|
@@ -73,18 +98,17 @@ module SolidObjects
|
|
|
73
98
|
new(
|
|
74
99
|
reference:,
|
|
75
100
|
component_name: payload.fetch("component_name"),
|
|
101
|
+
component_key: payload["component_key"],
|
|
76
102
|
dependencies:,
|
|
103
|
+
locals: payload.fetch("locals"),
|
|
104
|
+
refresh_method: payload.fetch("refresh_method"),
|
|
77
105
|
instance_id: payload.fetch("instance_id"),
|
|
78
106
|
revision: payload.fetch("revision"),
|
|
79
107
|
refresh_path: payload.fetch("refresh_path"),
|
|
80
108
|
token:
|
|
81
109
|
)
|
|
82
|
-
rescue UnknownActorType, UnknownComponentDependency => error
|
|
83
|
-
raise InvalidComponentToken, error.message
|
|
84
110
|
end
|
|
85
111
|
|
|
86
|
-
private
|
|
87
|
-
|
|
88
112
|
# @rbs (Reference, Array[String]) -> void
|
|
89
113
|
def validate_dependencies!(reference, dependencies)
|
|
90
114
|
actor_class = SolidObjects.registry.fetch(reference.actor_type)
|
|
@@ -104,6 +128,27 @@ module SolidObjects
|
|
|
104
128
|
[ instance_id, revision ]
|
|
105
129
|
end
|
|
106
130
|
|
|
131
|
+
# @rbs () -> String
|
|
132
|
+
def dom_id
|
|
133
|
+
DomIdentity.component(
|
|
134
|
+
reference,
|
|
135
|
+
component_name,
|
|
136
|
+
key: component_key
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @rbs () -> bool
|
|
141
|
+
def morph?
|
|
142
|
+
refresh_method == "morph"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @rbs () -> Hash[String, untyped]
|
|
146
|
+
def authorization_arguments
|
|
147
|
+
return locals unless component_key
|
|
148
|
+
|
|
149
|
+
locals.merge("component_key" => component_key).freeze
|
|
150
|
+
end
|
|
151
|
+
|
|
107
152
|
# @rbs (Integer, Integer) -> String
|
|
108
153
|
def refresh_url(instance_id, revision)
|
|
109
154
|
query = URI.encode_www_form(
|
|
@@ -3,46 +3,44 @@
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
class ComponentRenderer
|
|
5
5
|
# @rbs @snapshot: ActorSnapshot
|
|
6
|
-
# @rbs @
|
|
7
|
-
# @rbs @dependencies: Array[String]
|
|
6
|
+
# @rbs @registration: ComponentRegistration
|
|
8
7
|
# @rbs @view_context: untyped
|
|
9
8
|
# @rbs @authorization_context: untyped
|
|
10
9
|
|
|
11
|
-
# @rbs (snapshot: ActorSnapshot,
|
|
10
|
+
# @rbs (snapshot: ActorSnapshot, registration: ComponentRegistration, view_context: untyped, authorization_context: untyped) -> void
|
|
12
11
|
def initialize(
|
|
13
12
|
snapshot:,
|
|
14
|
-
|
|
15
|
-
dependencies:,
|
|
13
|
+
registration:,
|
|
16
14
|
view_context:,
|
|
17
15
|
authorization_context:
|
|
18
16
|
)
|
|
19
17
|
@snapshot = snapshot
|
|
20
|
-
@
|
|
21
|
-
@dependencies = dependencies
|
|
18
|
+
@registration = registration
|
|
22
19
|
@view_context = view_context
|
|
23
20
|
@authorization_context = authorization_context
|
|
24
21
|
end
|
|
25
22
|
|
|
26
23
|
# @rbs () -> untyped
|
|
27
24
|
def call
|
|
28
|
-
[ component_name, *dependencies ].uniq.each do |authorization_name|
|
|
25
|
+
[ registration.component_name, *registration.dependencies ].uniq.each do |authorization_name|
|
|
29
26
|
authorize_read!(authorization_name)
|
|
30
27
|
end
|
|
31
28
|
actor = ComponentView.new(
|
|
32
29
|
snapshot:,
|
|
33
|
-
dependencies
|
|
30
|
+
dependencies: registration.dependencies,
|
|
34
31
|
authorization_context:
|
|
35
32
|
)
|
|
36
33
|
view_context.render(
|
|
37
34
|
partial: default_partial,
|
|
38
|
-
locals:
|
|
35
|
+
locals: registration.locals.transform_keys(&:to_sym).merge(
|
|
39
36
|
actor:,
|
|
40
|
-
authorization_context
|
|
41
|
-
|
|
37
|
+
authorization_context:,
|
|
38
|
+
component_key: registration.component_key
|
|
39
|
+
)
|
|
42
40
|
)
|
|
43
41
|
rescue ActionView::MissingTemplate
|
|
44
42
|
raise UnknownComponent,
|
|
45
|
-
"unknown component #{component_name.inspect} for #{snapshot.reference.actor_type}"
|
|
43
|
+
"unknown component #{registration.component_name.inspect} for #{snapshot.reference.actor_type}"
|
|
46
44
|
rescue ActionView::Template::Error => error
|
|
47
45
|
raise error.cause if error.cause.is_a?(SolidObjects::Error)
|
|
48
46
|
|
|
@@ -52,8 +50,7 @@ module SolidObjects
|
|
|
52
50
|
private
|
|
53
51
|
|
|
54
52
|
attr_reader :snapshot,
|
|
55
|
-
:
|
|
56
|
-
:dependencies,
|
|
53
|
+
:registration,
|
|
57
54
|
:view_context,
|
|
58
55
|
:authorization_context
|
|
59
56
|
|
|
@@ -63,7 +60,7 @@ module SolidObjects
|
|
|
63
60
|
actor_type: snapshot.reference.actor_type,
|
|
64
61
|
actor_id: snapshot.reference.actor_id,
|
|
65
62
|
message_name: observable_name,
|
|
66
|
-
arguments:
|
|
63
|
+
arguments: registration.authorization_arguments,
|
|
67
64
|
authorization_context:
|
|
68
65
|
)
|
|
69
66
|
return if authorized
|
|
@@ -76,7 +73,7 @@ module SolidObjects
|
|
|
76
73
|
actor_name = snapshot.actor_class.name
|
|
77
74
|
raise InvalidActor, "anonymous actors cannot render reactive components" unless actor_name
|
|
78
75
|
|
|
79
|
-
"actors/#{actor_name.underscore}/#{component_name}"
|
|
76
|
+
"actors/#{actor_name.underscore}/#{registration.component_name}"
|
|
80
77
|
end
|
|
81
78
|
end
|
|
82
79
|
end
|
|
@@ -28,7 +28,7 @@ module SolidObjects
|
|
|
28
28
|
validate_identity!(registration, reference)
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
|
-
if registrations.map(&:
|
|
31
|
+
if registrations.map(&:dom_id).uniq.length != registrations.length
|
|
32
32
|
raise InvalidComponentToken, "duplicate actor component registration"
|
|
33
33
|
end
|
|
34
34
|
|
|
@@ -39,7 +39,7 @@ module SolidObjects
|
|
|
39
39
|
def initialize(registrations)
|
|
40
40
|
@registrations = registrations
|
|
41
41
|
@revisions = registrations.to_h do |registration|
|
|
42
|
-
[ registration.
|
|
42
|
+
[ registration.dom_id, registration.revision_key ]
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
45
|
|
|
@@ -51,7 +51,7 @@ module SolidObjects
|
|
|
51
51
|
registrations.filter_map do |registration|
|
|
52
52
|
next unless registration.dependencies.include?(observable_name)
|
|
53
53
|
next unless newer_revision?(
|
|
54
|
-
registration.
|
|
54
|
+
registration.dom_id,
|
|
55
55
|
instance_id,
|
|
56
56
|
revision
|
|
57
57
|
)
|
|
@@ -64,7 +64,7 @@ module SolidObjects
|
|
|
64
64
|
def reconnect_refreshes(snapshot)
|
|
65
65
|
registrations.filter_map do |registration|
|
|
66
66
|
next unless newer_revision?(
|
|
67
|
-
registration.
|
|
67
|
+
registration.dom_id,
|
|
68
68
|
snapshot.instance_id,
|
|
69
69
|
snapshot.revision
|
|
70
70
|
)
|
|
@@ -92,7 +92,7 @@ module SolidObjects
|
|
|
92
92
|
|
|
93
93
|
# @rbs (ComponentRegistration, Integer, Integer) -> String
|
|
94
94
|
def refresh(registration, instance_id, revision)
|
|
95
|
-
revisions[registration.
|
|
95
|
+
revisions[registration.dom_id] = [ instance_id, revision ]
|
|
96
96
|
TurboStreamRenderer.component_refresh(
|
|
97
97
|
registration,
|
|
98
98
|
instance_id,
|
|
@@ -101,8 +101,8 @@ module SolidObjects
|
|
|
101
101
|
end
|
|
102
102
|
|
|
103
103
|
# @rbs (String, Integer, Integer) -> bool
|
|
104
|
-
def newer_revision?(
|
|
105
|
-
current = revisions.fetch(
|
|
104
|
+
def newer_revision?(dom_id, instance_id, revision)
|
|
105
|
+
current = revisions.fetch(dom_id)
|
|
106
106
|
(current <=> [ instance_id, revision ]) == -1
|
|
107
107
|
end
|
|
108
108
|
end
|