ruact 0.0.15 → 0.0.16
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 +22 -1
- data/lib/generators/ruact/install/install_generator.rb +2 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +2 -2
- data/lib/ruact/component_attributes.rb +91 -0
- data/lib/ruact/configuration.rb +28 -0
- data/lib/ruact/erb_preprocessor.rb +1 -47
- data/lib/ruact/server.rb +11 -7
- data/lib/ruact/server_functions/action_assigns.rb +52 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +1 -1
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact.rb +1 -0
- data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +47 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +32 -4
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +9 -4
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cc962c661c39c1c9905f3aee2ddb2c956dc196218f283ef5532bf8c9826fe98f
|
|
4
|
+
data.tar.gz: c3211a4a820d0ad77daebcd5ee071f91b5f10c2be9153f0bb6e62cc8153c61c9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8dcd8f0f4988c3a3e0f9950e6210d68986d980256a2ff9463a2847107513ad8c3090c9949f728aaebc810ccf1937d7c52589f267dd60b5a12ae6a70f9e1ce95a
|
|
7
|
+
data.tar.gz: 4c7c1bf1221798c93d183456f07cb4d4549b8ec4bfa56ff091133edff176c77bd9709f303fcbf372ffea994530aaefcb92c069d66abe9e861578578b64f68c20
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.0.16] - 2026-10-03
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **A quoted attribute on a component was dropped without a word.** `<PostCard title="Hello" />` reached the component with no `title`: only `title={…}` was read, and the quoted form is the first thing a JSX hand writes. Component tags now take the three JSX forms. `name={ruby}` is a Ruby expression, as before — except that a hyphenated name keeps its hyphen: `aria-label={…}` used to arrive as `label`. `name="text"` is the string; a quoted value never runs Ruby. A bare `name` is `true`. Anything else raises a template error that names the attribute, including ERB inside a quoted value (`title="<%= @t %>"`), which a component attribute cannot run: write `title={@t}`.
|
|
15
|
+
|
|
16
|
+
- **The Getting Started page logged a React error.** Server-rendered siblings, such as an `<h1>` next to a component or the rows of an ERB loop, reached React as an array of elements with no keys, and React logged "Each child in a list should have a unique key" on the guide's own example. Siblings are now handed to React as separate children, the way JSX compiles them, so React does not check them for keys. That covers the page itself, any element's children and the content a `<Suspense>` boundary streams in. No key is invented, and a key the template gives still wins. A data array passed as a `children` prop is left as it is.
|
|
17
|
+
|
|
18
|
+
- **A form React handles itself also got a router request.** The client router decided on clicks and form submits before the app's own handlers ran, so a form whose `onSubmit` calls `preventDefault()` was also fetched by the router: the scaffold's form sent a stray `GET` of its own page on every save. The router now decides after them, and an event the app prevented is left alone. The same holds for a link whose `onClick` prevents the default. A handler that stops the event's propagation now also keeps it from the router, which then leaves the browser to follow the link or submit the form, as Turbo does.
|
|
19
|
+
|
|
20
|
+
- **The install message said a component works in any ERB view.** In island mode, the default, it works in the views of a controller that has `include Ruact::Controller`; the message now says so.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **A server function no longer returns what callbacks and auth libraries put on the controller, or answers 500 because of it.** A function call's JSON was the controller's whole `view_assigns`. An authentication library that memoizes the user in an instance variable (Devise's `current_user` sets `@current_user` on its first call) put that user in every response. Under `strict_serialization`, on by default in production, a user model without `ruact_props` then made every function call answer 500. Two rules now decide what the JSON holds:
|
|
25
|
+
- **What callbacks set stays out, unless the action assigns it a different object.** `@post = Post.find(...)` in the action is returned. A `before_action :set_post` whose `@post` the action only updates in place (`@post.update(...)`) is not: reassign it (`@post = @post.reload`) or `render json:`. This covers plain values too: a callback's `@current_user_id = 5` is not returned, and neither is a value the action sets to the very same object the callback stored (`@count = 0` after a callback did the same, or a memoized helper's result).
|
|
26
|
+
- **Authentication and authorization memos are never returned,** even when the action fills them by calling `current_user` or `authorize`. The names are in the new `config.server_function_hidden_ivars`, default `["current_user", "current_ability", "pundit"]` for Devise's `:user` scope, CanCanCan and Pundit. Every name starting with `_` is hidden too. Add the memo of any other scope or helper, such as Devise's `@current_admin` for `devise_for :admins`: `config.server_function_hidden_ivars += ["current_admin"]`.
|
|
27
|
+
|
|
28
|
+
A page render's view still sees every instance variable.
|
|
29
|
+
|
|
10
30
|
## [0.0.15] - 2026-10-03
|
|
11
31
|
|
|
12
32
|
### Security
|
|
@@ -416,7 +436,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
416
436
|
|
|
417
437
|
- **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
|
|
418
438
|
|
|
419
|
-
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.
|
|
439
|
+
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.16...HEAD
|
|
440
|
+
[0.0.16]: https://github.com/luizcg/ruact/releases/tag/v0.0.16
|
|
420
441
|
[0.0.15]: https://github.com/luizcg/ruact/releases/tag/v0.0.15
|
|
421
442
|
[0.0.14]: https://github.com/luizcg/ruact/releases/tag/v0.0.14
|
|
422
443
|
[0.0.13]: https://github.com/luizcg/ruact/releases/tag/v0.0.13
|
|
@@ -425,7 +425,8 @@ module Ruact
|
|
|
425
425
|
|
|
426
426
|
show_shadcn_next_steps if shadcn?
|
|
427
427
|
|
|
428
|
-
say "\nThen add <MyComponent /> to
|
|
428
|
+
say "\nThen add <MyComponent /> to an ERB view of a ruact page:" unless whole_app?
|
|
429
|
+
say "\nThen add <MyComponent /> to any ERB view." if whole_app?
|
|
429
430
|
say layout_summary
|
|
430
431
|
say ""
|
|
431
432
|
say "Note: re-run this generator after updating the ruact gem to refresh"
|
|
@@ -84,8 +84,8 @@ READ IT instead of simulating the name generation. Regenerate after routes/queri
|
|
|
84
84
|
`<Suspense fallback="...">...</Suspense>` pair.
|
|
85
85
|
2. **`{}` props are Ruby, not JavaScript.** `<Badge label={@post.title} />`
|
|
86
86
|
evaluates `@post.title` as a Ruby expression in the ERB. No JS ternaries,
|
|
87
|
-
no `{...spread}`, no JSX children.
|
|
88
|
-
|
|
87
|
+
no `{...spread}`, no JSX children. A quoted value (`title="Hi"`) is a
|
|
88
|
+
plain string and a bare attribute is `true`, as in JSX.
|
|
89
89
|
3. **One action, two response shapes.** A `Ruact::Server` non-GET action
|
|
90
90
|
answers JSON — its instance variables, or `204`, or `{"$redirect": path}` —
|
|
91
91
|
when the request's `Accept` header is exactly `application/json`, which is
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
# The attributes of a PascalCase component tag in ERB, read the way JSX
|
|
5
|
+
# reads them (see {ErbPreprocessor}). Returns ordered
|
|
6
|
+
# +[name, ruby_expr]+ pairs for the props Hash the template renders.
|
|
7
|
+
module ComponentAttributes
|
|
8
|
+
ATTR_NAME_RE = /\A[a-zA-Z_][\w-]*/
|
|
9
|
+
|
|
10
|
+
# Parses the attributes string of a component tag into ordered
|
|
11
|
+
# +[name, ruby_expr]+ pairs, e.g. [["postId", "@post.id"], ["title", "\"Hi\""]].
|
|
12
|
+
# The names feed the Story 13.5 contract check; the pairs render the props
|
|
13
|
+
# Hash. The three JSX attribute forms:
|
|
14
|
+
#
|
|
15
|
+
# name={ruby} — the Ruby expression (nested braces honored)
|
|
16
|
+
# name="text" — the string, as JSX passes it (single quotes too)
|
|
17
|
+
# name — +true+, as JSX passes a bare attribute
|
|
18
|
+
#
|
|
19
|
+
# Anything else raises instead of being dropped: an unbraced attribute used
|
|
20
|
+
# to vanish without a word, and it is the first thing a JSX hand writes.
|
|
21
|
+
def self.parse(attrs_string)
|
|
22
|
+
attrs = attrs_string.sub(%r{\s*/\z}, "")
|
|
23
|
+
pairs = []
|
|
24
|
+
i = 0
|
|
25
|
+
while i < attrs.length
|
|
26
|
+
if attrs[i].match?(/\s/)
|
|
27
|
+
i += 1
|
|
28
|
+
next
|
|
29
|
+
end
|
|
30
|
+
name = attrs[i..][ATTR_NAME_RE] ||
|
|
31
|
+
raise(PreprocessorError, "unexpected #{attrs[i, 20].inspect} in the component's attributes")
|
|
32
|
+
i += name.length
|
|
33
|
+
i += 1 while attrs[i]&.match?(/\s/)
|
|
34
|
+
if attrs[i] == "="
|
|
35
|
+
i += 1
|
|
36
|
+
i += 1 while attrs[i]&.match?(/\s/)
|
|
37
|
+
value, i = parse_value(attrs, i, name)
|
|
38
|
+
pairs << [name, value]
|
|
39
|
+
else
|
|
40
|
+
pairs << [name, "true"]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
pairs
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# One attribute value at +attrs[i]+ → [ruby_expr, index after it].
|
|
47
|
+
def self.parse_value(attrs, pos, name)
|
|
48
|
+
case attrs[pos]
|
|
49
|
+
when "{"
|
|
50
|
+
expr = extract_braced_expr(attrs, pos + 1)
|
|
51
|
+
raise PreprocessorError, "#{name}={} is empty: put a Ruby expression in the braces" if expr.strip.empty?
|
|
52
|
+
|
|
53
|
+
[expr, pos + expr.length + 2]
|
|
54
|
+
when '"', "'"
|
|
55
|
+
close = attrs.index(attrs[pos], pos + 1)
|
|
56
|
+
text = attrs[(pos + 1)...(close || attrs.length)]
|
|
57
|
+
if text.include?("<%")
|
|
58
|
+
raise PreprocessorError, "#{name}= holds ERB, which a component attribute cannot run. " \
|
|
59
|
+
"Pass the Ruby in braces instead: #{name}={...}"
|
|
60
|
+
end
|
|
61
|
+
unless close
|
|
62
|
+
raise PreprocessorError, "unclosed quote in #{name}= (a component tag cannot contain `>`, " \
|
|
63
|
+
"even inside a quoted value: pass the text from Ruby instead)"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
[text.inspect, close + 1]
|
|
67
|
+
else
|
|
68
|
+
raise PreprocessorError, "#{name}= needs a value: #{name}={ruby} or #{name}=\"text\""
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Given a string and a start position (just after the opening '{'),
|
|
73
|
+
# returns the content up to the matching '}'.
|
|
74
|
+
def self.extract_braced_expr(str, start)
|
|
75
|
+
depth = 1
|
|
76
|
+
i = start
|
|
77
|
+
while i < str.length && depth.positive?
|
|
78
|
+
case str[i]
|
|
79
|
+
when "{" then depth += 1
|
|
80
|
+
when "}" then depth -= 1
|
|
81
|
+
end
|
|
82
|
+
i += 1
|
|
83
|
+
end
|
|
84
|
+
raise PreprocessorError, "unclosed brace in prop expression" if depth.positive?
|
|
85
|
+
|
|
86
|
+
str[start...(i - 1)]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private_class_method :parse_value, :extract_braced_expr
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/ruact/configuration.rb
CHANGED
|
@@ -26,6 +26,7 @@ module Ruact
|
|
|
26
26
|
shadcn_compatible_versions
|
|
27
27
|
layout
|
|
28
28
|
layout_stylesheets
|
|
29
|
+
server_function_hidden_ivars
|
|
29
30
|
].freeze
|
|
30
31
|
|
|
31
32
|
# @!attribute [r] manifest_path
|
|
@@ -125,6 +126,19 @@ module Ruact
|
|
|
125
126
|
# @example Set an app-wide default expiry
|
|
126
127
|
# Ruact.configure { |c| c.signed_global_id_default_expires_in = 1.hour }
|
|
127
128
|
#
|
|
129
|
+
# @!attribute [r] server_function_hidden_ivars
|
|
130
|
+
# @return [Array<String>] Instance-variable names (no `@`) a server
|
|
131
|
+
# function's JSON never returns, even when the action set them: the
|
|
132
|
+
# memoization ivars of authentication and authorization libraries,
|
|
133
|
+
# which an action fills just by calling `current_user` or `authorize`.
|
|
134
|
+
# Names starting with `_` are always hidden. Default
|
|
135
|
+
# `["current_user", "current_ability", "pundit"]` (Devise's `:user`
|
|
136
|
+
# scope, CanCanCan, Pundit); another Devise scope memoizes its own
|
|
137
|
+
# `@current_<scope>` — add it. Assign to replace the list; to keep the
|
|
138
|
+
# defaults, add to them.
|
|
139
|
+
# @example Also hide an app's own memoized account
|
|
140
|
+
# Ruact.configure { |c| c.server_function_hidden_ivars += ["current_account"] }
|
|
141
|
+
#
|
|
128
142
|
# @!attribute [r] shadcn_compatible_versions
|
|
129
143
|
# @return [Array<Integer>] Story 10.5 — the shadcn/ui MAJOR versions the
|
|
130
144
|
# `ruact:scaffold` generator is regression-tested against. When the
|
|
@@ -255,6 +269,7 @@ module Ruact
|
|
|
255
269
|
@shadcn_compatible_versions = [1, 2, 4]
|
|
256
270
|
@layout = false
|
|
257
271
|
@layout_stylesheets = [:app]
|
|
272
|
+
@server_function_hidden_ivars = %w[current_user current_ability pundit]
|
|
258
273
|
end
|
|
259
274
|
end
|
|
260
275
|
|
|
@@ -278,6 +293,9 @@ module Ruact
|
|
|
278
293
|
# @api private
|
|
279
294
|
# @return [Ruact::Configuration] self, frozen
|
|
280
295
|
def seal!
|
|
296
|
+
# An Array attribute mutated in place inside the block (`list << x`)
|
|
297
|
+
# never passed through its writer; validate it here, before publication.
|
|
298
|
+
validate_attribute_value!(:server_function_hidden_ivars, server_function_hidden_ivars)
|
|
281
299
|
ATTRIBUTES.each do |attr|
|
|
282
300
|
value = public_send(attr)
|
|
283
301
|
next if value.nil? || value.frozen?
|
|
@@ -322,6 +340,7 @@ module Ruact
|
|
|
322
340
|
when :shadcn_compatible_versions then validate_shadcn_compatible_versions!(value)
|
|
323
341
|
when :layout then validate_layout!(value)
|
|
324
342
|
when :layout_stylesheets then validate_layout_stylesheets!(value)
|
|
343
|
+
when :server_function_hidden_ivars then validate_server_function_hidden_ivars!(value)
|
|
325
344
|
end
|
|
326
345
|
end
|
|
327
346
|
|
|
@@ -440,6 +459,15 @@ module Ruact
|
|
|
440
459
|
"major versions (e.g. [1, 2]); got #{value.inspect}."
|
|
441
460
|
end
|
|
442
461
|
|
|
462
|
+
# An Array of ivar names; a leading `@` is a typo that would hide nothing.
|
|
463
|
+
def validate_server_function_hidden_ivars!(value)
|
|
464
|
+
return if value.is_a?(Array) && value.all? { |name| name.is_a?(String) && !name.start_with?("@") }
|
|
465
|
+
|
|
466
|
+
raise Ruact::ConfigurationError,
|
|
467
|
+
"Ruact::Configuration#server_function_hidden_ivars must be an Array of instance-variable " \
|
|
468
|
+
"names without the @ (e.g. [\"current_user\"]); got #{value.inspect}."
|
|
469
|
+
end
|
|
470
|
+
|
|
443
471
|
def build_error_message(attr, location)
|
|
444
472
|
<<~MSG.strip
|
|
445
473
|
ruact: cannot mutate Ruact::Configuration##{attr} after initialization.
|
|
@@ -51,11 +51,6 @@ module Ruact
|
|
|
51
51
|
# unrelated `<% "</Dialog>" %>` appears later).
|
|
52
52
|
ERB_ISLAND_RE = /<%.*?%>/m
|
|
53
53
|
|
|
54
|
-
# Matches a +{ruby_expr}+ attribute value — captures everything between the braces.
|
|
55
|
-
# We use a simple bracket-depth counter approach during scanning instead of regex
|
|
56
|
-
# because expressions can contain nested braces: {foo.bar({ a: 1 })}.
|
|
57
|
-
PROP_RE = /\b([a-zA-Z_][a-zA-Z0-9_]*)=\{/
|
|
58
|
-
|
|
59
54
|
# Transform ERB source, replacing component tags with ERB placeholders.
|
|
60
55
|
# Returns the transformed source string.
|
|
61
56
|
#
|
|
@@ -114,7 +109,7 @@ module Ruact
|
|
|
114
109
|
# the render path surfaces the clear error). In prod this is the
|
|
115
110
|
# boot-loaded Ruact.manifest, unchanged.
|
|
116
111
|
registry = ManifestResolver.resolve_soft if registry == :default
|
|
117
|
-
pairs =
|
|
112
|
+
pairs = ComponentAttributes.parse(attrs_string)
|
|
118
113
|
validate_contract(registry, component_name, pairs.map(&:first),
|
|
119
114
|
at: { file: identifier, line: line, snippet: match.strip })
|
|
120
115
|
props_ruby = pairs.map { |name, expr| "#{name.inspect} => #{expr}" }.join(", ")
|
|
@@ -247,46 +242,5 @@ module Ruact
|
|
|
247
242
|
m = identifier.to_s.match(%r{app/views/(.+)/[^/]+\z})
|
|
248
243
|
m && m[1]
|
|
249
244
|
end
|
|
250
|
-
|
|
251
|
-
# Parses the attributes string of a component tag into ordered
|
|
252
|
-
# +[name, ruby_expr]+ pairs, e.g. [["postId", "@post.id"], ["count", "5"]].
|
|
253
|
-
# Honors nested braces in values (via {#extract_braced_expr}). The names
|
|
254
|
-
# feed the Story 13.5 contract check; the pairs render the props Hash.
|
|
255
|
-
def parse_prop_pairs(attrs_string)
|
|
256
|
-
return [] if attrs_string.empty?
|
|
257
|
-
|
|
258
|
-
pairs = []
|
|
259
|
-
remaining = attrs_string.dup
|
|
260
|
-
|
|
261
|
-
while (m = PROP_RE.match(remaining))
|
|
262
|
-
prop_name = m[1]
|
|
263
|
-
# Find the matching closing brace, respecting nesting
|
|
264
|
-
value_start = m.end(0)
|
|
265
|
-
value_expr = extract_braced_expr(remaining, value_start)
|
|
266
|
-
pairs << [prop_name, value_expr]
|
|
267
|
-
# Advance past this prop
|
|
268
|
-
remaining = remaining[(value_start + value_expr.length + 1)..] # +1 for closing }
|
|
269
|
-
break if remaining.nil?
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
pairs
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
# Given a string and a start position (just after the opening '{'),
|
|
276
|
-
# returns the content up to the matching '}'.
|
|
277
|
-
def extract_braced_expr(str, start)
|
|
278
|
-
depth = 1
|
|
279
|
-
i = start
|
|
280
|
-
while i < str.length && depth.positive?
|
|
281
|
-
case str[i]
|
|
282
|
-
when "{" then depth += 1
|
|
283
|
-
when "}" then depth -= 1
|
|
284
|
-
end
|
|
285
|
-
i += 1
|
|
286
|
-
end
|
|
287
|
-
raise PreprocessorError, "unclosed brace in prop expression" if depth.positive?
|
|
288
|
-
|
|
289
|
-
str[start...(i - 1)]
|
|
290
|
-
end
|
|
291
245
|
end
|
|
292
246
|
end
|
data/lib/ruact/server.rb
CHANGED
|
@@ -14,6 +14,7 @@ require_relative "../ruact"
|
|
|
14
14
|
require_relative "server_functions/error_rendering"
|
|
15
15
|
require_relative "server_functions/bucket_two_payload"
|
|
16
16
|
require_relative "server_functions/name_bridge"
|
|
17
|
+
require_relative "server_functions/action_assigns"
|
|
17
18
|
require_relative "redirect_path"
|
|
18
19
|
require_relative "validation_errors_collector"
|
|
19
20
|
|
|
@@ -69,6 +70,9 @@ module Ruact
|
|
|
69
70
|
module Server
|
|
70
71
|
extend ActiveSupport::Concern
|
|
71
72
|
|
|
73
|
+
# A function call returns the action's own ivars, not its callbacks'.
|
|
74
|
+
include Ruact::ServerFunctions::ActionAssigns
|
|
75
|
+
|
|
72
76
|
include Ruact::ServerFunctions::ErrorRendering
|
|
73
77
|
include Ruact::ValidationErrorsCollector
|
|
74
78
|
|
|
@@ -202,16 +206,16 @@ module Ruact
|
|
|
202
206
|
# Story 9.2 AC2/AC4 (D1) — Bucket-2 success-path negotiation. When the
|
|
203
207
|
# action finished without an explicit render on a function-call request
|
|
204
208
|
# ({#__ruact_function_call?} — `Accept: application/json`, non-GET), serialize
|
|
205
|
-
# the
|
|
206
|
-
#
|
|
209
|
+
# the instance variables the ACTION assigned (Rails `view_assigns` minus
|
|
210
|
+
# what callbacks set and the action left alone — see
|
|
211
|
+
# {Ruact::ServerFunctions::ActionAssigns}) as a JSON object keyed by ivar name, or
|
|
207
212
|
# `204 No Content` when none were set. Any other request shape falls through
|
|
208
213
|
# to `super` so Bucket-1 rendering — the host's `Ruact::Controller` Flight
|
|
209
214
|
# re-render, then Rails — is byte-for-byte unchanged (AC1).
|
|
210
215
|
#
|
|
211
|
-
#
|
|
212
|
-
#
|
|
213
|
-
#
|
|
214
|
-
# exactly what the action assigned. Each value is serialized through the
|
|
216
|
+
# Rails' `view_assigns` already excludes its protected `@_`-prefixed
|
|
217
|
+
# internals (including the CSRF `@_marked_for_same_origin_verification`
|
|
218
|
+
# flag); ActionAssigns then drops what callbacks set. Each value is serialized through the
|
|
215
219
|
# `ruact_props` / `Ruact::Serializable` / `strict_serialization` rules
|
|
216
220
|
# ({Ruact::ServerFunctions::BucketTwoPayload}); a single ivar stays keyed
|
|
217
221
|
# (no magic unwrap).
|
|
@@ -222,7 +226,7 @@ module Ruact
|
|
|
222
226
|
# dev `@errors`) are dropped from the serialized assigns; Rails' own
|
|
223
227
|
# `view_assigns` only filters a fixed set of `@_` ivars, not every `@__`
|
|
224
228
|
# one (see {Ruact::ValidationErrorsCollector::RESERVED_ASSIGN_KEYS}).
|
|
225
|
-
assigns = view_assigns.except(*ValidationErrorsCollector::RESERVED_ASSIGN_KEYS)
|
|
229
|
+
assigns = __ruact_action_assigns(view_assigns.except(*ValidationErrorsCollector::RESERVED_ASSIGN_KEYS))
|
|
226
230
|
|
|
227
231
|
# Story 15.0 (F6) — reaching this branch means ruact itself is producing
|
|
228
232
|
# the function-call response (fall-through: injection, plain JSON, or
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module ServerFunctions
|
|
5
|
+
# What a server function's JSON is made of (decision, 2026-10-03): the
|
|
6
|
+
# ivars the ACTION assigned, not everything a view would see.
|
|
7
|
+
#
|
|
8
|
+
# Two filters. Callbacks run before `send_action`, so what they put on the
|
|
9
|
+
# controller (a `set_post`, a `set_current_user`) is recorded there and
|
|
10
|
+
# left out of the JSON unless the action assigns it again. And some ivars
|
|
11
|
+
# are filled DURING the action without the action naming them — Devise's
|
|
12
|
+
# `current_user` memoizes `@current_user` on first call, Pundit's
|
|
13
|
+
# `authorize` sets `@pundit` — so names in
|
|
14
|
+
# `Ruact.config.server_function_hidden_ivars`, and every name starting
|
|
15
|
+
# with `_`, are never returned. Without these, a function call carried the
|
|
16
|
+
# signed-in user, and under `strict_serialization` (production's default)
|
|
17
|
+
# a user model without `ruact_props` made every call a 500. A page
|
|
18
|
+
# render's view still sees every ivar; only the function-call JSON
|
|
19
|
+
# narrows.
|
|
20
|
+
module ActionAssigns
|
|
21
|
+
# The snapshot's own name, kept out of what it filters.
|
|
22
|
+
CALLBACK_IVARS_KEY = "__ruact_callback_ivars"
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def send_action(...)
|
|
27
|
+
if __ruact_function_call?
|
|
28
|
+
@__ruact_callback_ivars = instance_variables.to_h do |name|
|
|
29
|
+
[name.to_s.delete_prefix("@"), instance_variable_get(name)]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# +assigns+ minus what callbacks set and the action left holding the
|
|
36
|
+
# same object. Only a DIFFERENT object makes it the action's: setting
|
|
37
|
+
# the same value again (`@ok = false` after a callback did, a memoized
|
|
38
|
+
# helper returning what the callback already stored) leaves it out.
|
|
39
|
+
# Security first — a callback's `@current_user_id = 5` must not ride
|
|
40
|
+
# every response — at the price of a key that is absent when callback
|
|
41
|
+
# and action happen to agree.
|
|
42
|
+
def __ruact_action_assigns(assigns)
|
|
43
|
+
before = @__ruact_callback_ivars || {}
|
|
44
|
+
hidden = Ruact.config.server_function_hidden_ivars
|
|
45
|
+
assigns.reject do |name, value|
|
|
46
|
+
name == CALLBACK_IVARS_KEY || name.start_with?("_") || hidden.include?(name) ||
|
|
47
|
+
(before.key?(name) && before[name].equal?(value))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -7,7 +7,7 @@ require_relative "../errors"
|
|
|
7
7
|
module Ruact
|
|
8
8
|
module ServerFunctions
|
|
9
9
|
# Story 9.2 — pure serializer for the Bucket-2 (imperative `await fn()`)
|
|
10
|
-
# response body. Takes the
|
|
10
|
+
# response body. Takes the instance variables the action returns (Rails
|
|
11
11
|
# `view_assigns`, resolved by the caller) and produces a JSON-ready Ruby
|
|
12
12
|
# Hash, keyed by ivar name, applying the SAME prop-exposure policy as the
|
|
13
13
|
# Flight serializer ({Ruact::Flight::Serializer#serialize_unknown}):
|
data/lib/ruact/version.rb
CHANGED
data/lib/ruact.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative "ruact/flight"
|
|
|
9
9
|
require_relative "ruact/string_distance"
|
|
10
10
|
require_relative "ruact/layout_source"
|
|
11
11
|
require_relative "ruact/component_contract"
|
|
12
|
+
require_relative "ruact/component_attributes"
|
|
12
13
|
require_relative "ruact/erb_preprocessor"
|
|
13
14
|
require_relative "ruact/render_context"
|
|
14
15
|
require_relative "ruact/html_converter"
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
// against shuffling being turned on later.
|
|
32
32
|
|
|
33
33
|
import { describe, it, expect, beforeEach } from "vitest";
|
|
34
|
+
import { Fragment } from "react";
|
|
34
35
|
import fs from "node:fs";
|
|
35
36
|
import path from "node:path";
|
|
36
37
|
import {
|
|
@@ -450,3 +451,49 @@ describe("dates and big integers (Story 17-0c)", () => {
|
|
|
450
451
|
expect(tree.literal).toBe("$D2026-01-01 is a string");
|
|
451
452
|
});
|
|
452
453
|
});
|
|
454
|
+
|
|
455
|
+
// A server-rendered tree has unkeyed siblings (an <h1> beside a component, the
|
|
456
|
+
// rows of an ERB loop). Handed to React as an array they made it log "Each
|
|
457
|
+
// child in a list should have a unique key" on the Getting Started page. They
|
|
458
|
+
// are passed as separate children instead — static, like JSX — and no key is
|
|
459
|
+
// invented (an invented index key can collide with a template's own key and
|
|
460
|
+
// duplicate a row, or reset state when a sibling comes or goes).
|
|
461
|
+
describe("sibling children", () => {
|
|
462
|
+
const payload = [
|
|
463
|
+
'1:I["/TaskList.jsx","TaskList"]',
|
|
464
|
+
'0:[["$","h1",null,{"children":"Hello"}],["$","$L1",null,{"tasks":[]}],["$","ul",null,{"children":[["$","li",null,{"children":"a"}],["$","li","explicit",{"children":"b"}]]}]]',
|
|
465
|
+
"",
|
|
466
|
+
].join("\n");
|
|
467
|
+
|
|
468
|
+
it("wraps several root siblings in a Fragment, without inventing keys", () => {
|
|
469
|
+
const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
|
|
470
|
+
expect(tree.type).toBe(Fragment);
|
|
471
|
+
expect(tree.props.children.map((el) => el.key)).toEqual([null, null, null]);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("keeps the template's own key and invents none for the others", () => {
|
|
475
|
+
const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
|
|
476
|
+
const ul = tree.props.children[2];
|
|
477
|
+
expect(ul.props.children.map((el) => el.key)).toEqual([null, "explicit"]);
|
|
478
|
+
expect(ul.props.children).toHaveLength(2);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("wraps a Suspense boundary's deferred siblings the same way", () => {
|
|
482
|
+
const deferred = buildTree([["$", "h2", null, { children: "A" }], ["$", "p", null, { children: "B" }]], EMPTY_ROWS, MODULE_REGISTRY);
|
|
483
|
+
expect(deferred.type).toBe(Fragment);
|
|
484
|
+
expect(deferred.props.children).toHaveLength(2);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("does not spread a data array passed as children (it stays mutable)", () => {
|
|
488
|
+
const tree = createFromFlightPayload(payloadWithProps({ children: ["a", "b", "c"] }), MODULE_REGISTRY);
|
|
489
|
+
expect(tree.props.children).toEqual(["a", "b", "c"]);
|
|
490
|
+
expect(Object.isFrozen(tree.props.children)).toBe(false);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("leaves a single root, data arrays and one-element children as they were", () => {
|
|
494
|
+
const tree = createFromFlightPayload(payloadWithProps({ tasks: [{ id: 1 }, { id: 2 }] }), MODULE_REGISTRY);
|
|
495
|
+
expect(tree.key).toBeNull();
|
|
496
|
+
expect(tree.props.tasks).toEqual([{ id: 1 }, { id: 2 }]);
|
|
497
|
+
expect(createFromFlightPayload('0:[1,2]\n', MODULE_REGISTRY)).toEqual([1, 2]);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* - "$L{hex}" referencing a missing row → React.lazy() that resolves when the row arrives
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import { createElement, Fragment, lazy, Suspense } from "react";
|
|
29
|
+
import { createElement, Fragment, isValidElement, lazy, Suspense } from "react";
|
|
30
30
|
|
|
31
31
|
// ---------------------------------------------------------------------------
|
|
32
32
|
// Pending chunk registry — used for streaming Suspense deferred rows
|
|
@@ -273,7 +273,7 @@ export function buildTreeFromRows(rows, moduleRegistry) {
|
|
|
273
273
|
* @param {Object} moduleRegistry
|
|
274
274
|
*/
|
|
275
275
|
export function buildTree(value, rows, moduleRegistry) {
|
|
276
|
-
return _buildTree(value, rows, moduleRegistry);
|
|
276
|
+
return rootTree(_buildTree(value, rows, moduleRegistry));
|
|
277
277
|
}
|
|
278
278
|
|
|
279
279
|
/**
|
|
@@ -341,7 +341,7 @@ function _buildTree(value, rows, moduleRegistry) {
|
|
|
341
341
|
}
|
|
342
342
|
// Model row — deferred content already arrived (non-streaming path).
|
|
343
343
|
// Wrap in a function component so it can be used as a type in createElement.
|
|
344
|
-
const content = _buildTree(row.value, rows, moduleRegistry);
|
|
344
|
+
const content = rootTree(_buildTree(row.value, rows, moduleRegistry));
|
|
345
345
|
return () => content;
|
|
346
346
|
}
|
|
347
347
|
return value;
|
|
@@ -355,7 +355,7 @@ function _buildTree(value, rows, moduleRegistry) {
|
|
|
355
355
|
const type = resolveType(rawType, rows, moduleRegistry);
|
|
356
356
|
const props = buildProps(rawProps, rows, moduleRegistry);
|
|
357
357
|
if (key != null) props.key = key;
|
|
358
|
-
return
|
|
358
|
+
return createWithChildren(type, props);
|
|
359
359
|
}
|
|
360
360
|
|
|
361
361
|
// Plain array — fragment children OR a data array sitting in a prop.
|
|
@@ -384,6 +384,34 @@ function _buildTree(value, rows, moduleRegistry) {
|
|
|
384
384
|
return value;
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
+
// A server-rendered tree is static: siblings (an <h1> beside a component, the
|
|
388
|
+
// rows of an ERB loop) carry no key on the wire. Handed to React as an ARRAY,
|
|
389
|
+
// they made it log "Each child in a list should have a unique key" — on the
|
|
390
|
+
// Getting Started page itself. Passed as separate arguments, the way JSX
|
|
391
|
+
// compiles `<div><h1/><p/></div>`, they are static children React does not
|
|
392
|
+
// key-check, and nothing is invented: a key the template gave still wins, and
|
|
393
|
+
// reconciliation is the one React does for JSX. Two or more only — a single
|
|
394
|
+
// child or an explicit one-element array keeps its shape (Story 17-0a).
|
|
395
|
+
function createWithChildren(type, props) {
|
|
396
|
+
const { children } = props;
|
|
397
|
+
if (!Array.isArray(children) || children.length < 2 || !children.some(isValidElement)) {
|
|
398
|
+
return createElement(type, props); // a data array passed as `children` stays a plain array
|
|
399
|
+
}
|
|
400
|
+
const rest = { ...props };
|
|
401
|
+
delete rest.children;
|
|
402
|
+
return createElement(type, rest, ...children);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// The page's root — or a Suspense boundary's deferred content — when it is
|
|
406
|
+
// several siblings: a Fragment of them, for the same reason as above (React
|
|
407
|
+
// logged the warning against <App>).
|
|
408
|
+
function rootTree(tree) {
|
|
409
|
+
if (Array.isArray(tree) && tree.length >= 2 && tree.some(isValidElement)) {
|
|
410
|
+
return createElement(Fragment, null, ...tree);
|
|
411
|
+
}
|
|
412
|
+
return tree;
|
|
413
|
+
}
|
|
414
|
+
|
|
387
415
|
function resolveType(rawType, rows, moduleRegistry) {
|
|
388
416
|
if (typeof rawType === "string") {
|
|
389
417
|
if (rawType === "$SS") return Suspense; // React.Suspense
|
|
@@ -33,8 +33,13 @@ export function setupRouter({ onNavigate, moduleRegistry, onError = null }) {
|
|
|
33
33
|
_moduleRegistry = moduleRegistry;
|
|
34
34
|
_onError = onError;
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
// Bubble phase, on the document: the app's own handlers (React's, which
|
|
37
|
+
// listen on the root container, and any other) run first, so an
|
|
38
|
+
// `onSubmit`/`onClick` that calls preventDefault() keeps the event. In the
|
|
39
|
+
// capture phase the router decided before them: a React form that handles
|
|
40
|
+
// its own submit (the scaffold's) also got a router fetch of its URL.
|
|
41
|
+
document.addEventListener("click", handleClick);
|
|
42
|
+
document.addEventListener("submit", handleSubmit);
|
|
38
43
|
window.addEventListener("popstate", handlePopstate);
|
|
39
44
|
|
|
40
45
|
// Story 8.2 — publish the revalidate handle the runtime helper reads.
|
|
@@ -56,8 +61,8 @@ export function setupRouter({ onNavigate, moduleRegistry, onError = null }) {
|
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
export function teardownRouter() {
|
|
59
|
-
document.removeEventListener("click", handleClick
|
|
60
|
-
document.removeEventListener("submit", handleSubmit
|
|
64
|
+
document.removeEventListener("click", handleClick);
|
|
65
|
+
document.removeEventListener("submit", handleSubmit);
|
|
61
66
|
window.removeEventListener("popstate", handlePopstate);
|
|
62
67
|
_onNavigate = null;
|
|
63
68
|
_moduleRegistry = null;
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruact
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.16
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Luiz Garcia
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: nokogiri
|
|
@@ -72,6 +72,7 @@ files:
|
|
|
72
72
|
- lib/generators/ruact/scaffold/templates/views/show.html.erb.tt
|
|
73
73
|
- lib/ruact.rb
|
|
74
74
|
- lib/ruact/client_manifest.rb
|
|
75
|
+
- lib/ruact/component_attributes.rb
|
|
75
76
|
- lib/ruact/component_contract.rb
|
|
76
77
|
- lib/ruact/configuration.rb
|
|
77
78
|
- lib/ruact/controller.rb
|
|
@@ -105,6 +106,7 @@ files:
|
|
|
105
106
|
- lib/ruact/serializable.rb
|
|
106
107
|
- lib/ruact/server.rb
|
|
107
108
|
- lib/ruact/server_functions.rb
|
|
109
|
+
- lib/ruact/server_functions/action_assigns.rb
|
|
108
110
|
- lib/ruact/server_functions/backtrace_cleaner.rb
|
|
109
111
|
- lib/ruact/server_functions/bucket_two_payload.rb
|
|
110
112
|
- lib/ruact/server_functions/codegen.rb
|