ruact 0.0.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.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +166 -0
  3. data/.rubocop.yml +89 -0
  4. data/CHANGELOG.md +32 -0
  5. data/README.md +35 -0
  6. data/RELEASING.md +203 -0
  7. data/Rakefile +10 -0
  8. data/SECURITY.md +62 -0
  9. data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +163 -0
  10. data/lib/generators/ruact/install/install_generator.rb +100 -0
  11. data/lib/generators/ruact/install/templates/application.jsx.tt +51 -0
  12. data/lib/generators/ruact/install/templates/initializer.rb.tt +18 -0
  13. data/lib/generators/ruact/install/templates/vite.config.js.tt +26 -0
  14. data/lib/ruact/client_manifest.rb +115 -0
  15. data/lib/ruact/component_registry.rb +31 -0
  16. data/lib/ruact/configuration.rb +32 -0
  17. data/lib/ruact/controller.rb +195 -0
  18. data/lib/ruact/doctor.rb +84 -0
  19. data/lib/ruact/erb_preprocessor.rb +120 -0
  20. data/lib/ruact/erb_preprocessor_hook.rb +20 -0
  21. data/lib/ruact/errors.rb +14 -0
  22. data/lib/ruact/flight/react_element.rb +40 -0
  23. data/lib/ruact/flight/renderer.rb +73 -0
  24. data/lib/ruact/flight/request.rb +54 -0
  25. data/lib/ruact/flight/row_emitter.rb +37 -0
  26. data/lib/ruact/flight/serializer.rb +215 -0
  27. data/lib/ruact/flight.rb +12 -0
  28. data/lib/ruact/html_converter.rb +159 -0
  29. data/lib/ruact/railtie.rb +99 -0
  30. data/lib/ruact/render_pipeline.rb +107 -0
  31. data/lib/ruact/serializable.rb +58 -0
  32. data/lib/ruact/version.rb +5 -0
  33. data/lib/ruact/view_helper.rb +23 -0
  34. data/lib/ruact.rb +48 -0
  35. data/lib/rubocop/cop/ruact/no_extend_self.rb +46 -0
  36. data/lib/rubocop/cop/ruact/no_io_in_flight.rb +72 -0
  37. data/lib/rubocop/cop/ruact/no_shared_state.rb +49 -0
  38. data/lib/rubocop/cop/ruact.rb +5 -0
  39. data/lib/tasks/benchmark.rake +70 -0
  40. data/lib/tasks/rsc.rake +9 -0
  41. data/sig/ruact.rbs +4 -0
  42. data/spec/benchmarks/baseline.json +1 -0
  43. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +92 -0
  44. data/spec/fixtures/flight/README.md +88 -0
  45. data/spec/fixtures/flight/array.txt +1 -0
  46. data/spec/fixtures/flight/as_json_object.txt +2 -0
  47. data/spec/fixtures/flight/boolean_false.txt +1 -0
  48. data/spec/fixtures/flight/boolean_true.txt +1 -0
  49. data/spec/fixtures/flight/client_component_with_props.txt +2 -0
  50. data/spec/fixtures/flight/client_reference.txt +2 -0
  51. data/spec/fixtures/flight/hash.txt +1 -0
  52. data/spec/fixtures/flight/nil.txt +1 -0
  53. data/spec/fixtures/flight/number_float.txt +1 -0
  54. data/spec/fixtures/flight/number_integer.txt +1 -0
  55. data/spec/fixtures/flight/react_element_no_props.txt +1 -0
  56. data/spec/fixtures/flight/redirect_row.txt +1 -0
  57. data/spec/fixtures/flight/serializable_object.txt +2 -0
  58. data/spec/fixtures/flight/string_basic.txt +1 -0
  59. data/spec/fixtures/flight/string_dollar_escape.txt +1 -0
  60. data/spec/ruact/client_manifest_spec.rb +126 -0
  61. data/spec/ruact/controller_spec.rb +213 -0
  62. data/spec/ruact/doctor_spec.rb +234 -0
  63. data/spec/ruact/erb_preprocessor_hook_spec.rb +52 -0
  64. data/spec/ruact/erb_preprocessor_spec.rb +89 -0
  65. data/spec/ruact/errors_spec.rb +43 -0
  66. data/spec/ruact/flight/renderer_spec.rb +122 -0
  67. data/spec/ruact/flight/serializer_spec.rb +453 -0
  68. data/spec/ruact/html_converter_spec.rb +147 -0
  69. data/spec/ruact/install_generator_spec.rb +212 -0
  70. data/spec/ruact/railtie_spec.rb +156 -0
  71. data/spec/ruact/render_pipeline_spec.rb +474 -0
  72. data/spec/ruact/serializable_spec.rb +53 -0
  73. data/spec/ruact/view_helper_spec.rb +46 -0
  74. data/spec/spec_helper.rb +16 -0
  75. data/spec/support/matchers/flight_fixture_matcher.rb +25 -0
  76. data/spec/support/rails_stub.rb +45 -0
  77. data/vendor/javascript/vite-plugin-ruact/index.js +163 -0
  78. metadata +136 -0
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "date"
5
+
6
+ module Ruact
7
+ module Flight
8
+ # Converts a Ruby value/element tree into Flight wire format rows.
9
+ # All methods return the *inline* representation of the value
10
+ # (what goes inside a parent row). Side effects go into request queues.
11
+ class Serializer
12
+ # Strings larger than this are outlined into their own T row.
13
+ LARGE_TEXT_THRESHOLD = 1024
14
+
15
+ def initialize(request)
16
+ @request = request
17
+ end
18
+
19
+ # Entry point. Returns a value safe to pass to JSON.generate.
20
+ def serialize_model(value)
21
+ case value
22
+ when NilClass
23
+ nil
24
+ when TrueClass, FalseClass
25
+ value
26
+ when Integer
27
+ serialize_integer(value)
28
+ when Float
29
+ serialize_float(value)
30
+ when String
31
+ serialize_string(value)
32
+ when Symbol
33
+ serialize_symbol(value)
34
+ when Time, DateTime
35
+ serialize_date(value)
36
+ when ClientReference
37
+ serialize_client_reference(value)
38
+ when SuspenseElement
39
+ serialize_suspense(value)
40
+ when ReactElement
41
+ serialize_element(value)
42
+ when Array
43
+ serialize_array(value)
44
+ when Hash
45
+ serialize_hash(value)
46
+ else
47
+ serialize_unknown(value)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ # --- Primitives ---
54
+
55
+ def serialize_string(value)
56
+ # Large strings get their own T row
57
+ if value.bytesize >= LARGE_TEXT_THRESHOLD
58
+ id = @request.allocate_id
59
+ @request.increment_pending
60
+ row = RowEmitter.text(id, value)
61
+ @request.completed_regular_chunks << row
62
+ return "$T#{id.to_s(16)}"
63
+ end
64
+
65
+ # Escape leading $ so the client doesn't misinterpret it.
66
+ # "$danger" → "$$danger" (prepend one extra $, not two)
67
+ value.start_with?("$") ? "$#{value}" : value
68
+ end
69
+
70
+ def serialize_integer(value)
71
+ # BigInt range check (JS safe integer: ±2^53 - 1)
72
+ if value.abs > 9_007_199_254_740_991
73
+ "$n#{value}"
74
+ else
75
+ value
76
+ end
77
+ end
78
+
79
+ def serialize_float(value)
80
+ return "$NaN" if value.nan?
81
+ return "$Infinity" if value.infinite? == 1
82
+ return "$-Infinity" if value.infinite? == -1
83
+ return "$-0" if value.zero? && (1.0 / value).infinite? == -1
84
+
85
+ value
86
+ end
87
+
88
+ def serialize_symbol(value)
89
+ # Only :undefined is special for now
90
+ return "$undefined" if value == :undefined
91
+
92
+ # Unknown symbols: just use the name as a string
93
+ value.to_s
94
+ end
95
+
96
+ def serialize_date(value)
97
+ "$D#{value.iso8601(3)}"
98
+ end
99
+
100
+ # --- Collections ---
101
+
102
+ def serialize_array(value)
103
+ value.map { |v| serialize_model(v) }
104
+ end
105
+
106
+ def serialize_hash(value)
107
+ value.transform_keys(&:to_s).transform_values { |v| serialize_model(v) }
108
+ end
109
+
110
+ # --- React Element ---
111
+
112
+ def serialize_element(element)
113
+ type = element.type
114
+
115
+ resolved_type = case type
116
+ when String
117
+ # DOM element ("div", "span", etc.) — pass through as-is
118
+ type
119
+ when ClientReference
120
+ serialize_client_reference(type)
121
+ else
122
+ raise TypeError, "Unsupported element type: #{type.inspect}"
123
+ end
124
+
125
+ key = element.key
126
+ props = serialize_hash(element.props)
127
+
128
+ ["$", resolved_type, key, props]
129
+ end
130
+
131
+ # --- Suspense Boundary ---
132
+
133
+ def serialize_suspense(element)
134
+ # Allocate ID for the deferred content row (emitted after root row + delay)
135
+ deferred_id = @request.allocate_id
136
+ @request.deferred_chunks << { id: deferred_id, element: element.children, delay: element.delay }
137
+
138
+ fallback_value = element.fallback ? serialize_model(element.fallback) : nil
139
+
140
+ # Children is an element tuple using the lazy ref as its type
141
+ lazy_ref = "$L#{deferred_id.to_s(16)}"
142
+ children_el = ["$", lazy_ref, nil, {}]
143
+
144
+ ["$", "$SS", nil, { "fallback" => fallback_value, "children" => children_el }]
145
+ end
146
+
147
+ # --- Unknown type fallback ---
148
+
149
+ def serialize_unknown(value)
150
+ return serialize_serializable(value) if value.is_a?(Ruact::Serializable)
151
+
152
+ if value.respond_to?(:as_json)
153
+ if @request.strict_serialization
154
+ raise Ruact::SerializationError,
155
+ "Cannot serialize #{value.class.name} — " \
156
+ "include Ruact::Serializable or set strict_serialization: false"
157
+ else
158
+ serialize_as_json(value)
159
+ end
160
+ else
161
+ raise Ruact::SerializationError,
162
+ "Cannot serialize #{value.class.name} — include Ruact::Serializable"
163
+ end
164
+ end
165
+
166
+ # --- Serializable (explicit opt-in, no warning) ---
167
+
168
+ def serialize_serializable(value)
169
+ serialize_model(value.rsc_serialize)
170
+ end
171
+
172
+ # --- as_json fallback (strict_serialization: false only) ---
173
+
174
+ def serialize_as_json(value)
175
+ data = begin
176
+ value.as_json
177
+ rescue StandardError => e
178
+ raise Ruact::SerializationError,
179
+ "#{value.class.name}#as_json raised #{e.class}: #{e.message}"
180
+ end
181
+
182
+ if data.equal?(value)
183
+ raise Ruact::SerializationError,
184
+ "#{value.class.name}#as_json returned self — would cause infinite recursion. " \
185
+ "Include Ruact::Serializable and declare rsc_props instead"
186
+ end
187
+
188
+ attr_names = data.is_a?(Hash) ? data.keys.join(", ") : ""
189
+ @request.on_as_json_warning&.call(value.class.name, attr_names)
190
+ serialize_model(data)
191
+ end
192
+
193
+ # --- Client Reference ---
194
+
195
+ def serialize_client_reference(ref)
196
+ # Deduplication: same object → same $L reference
197
+ existing = @request.written_objects[ref]
198
+ return existing if existing
199
+
200
+ metadata = @request.bundler_config.resolve(ref.module_id, ref.export_name)
201
+
202
+ id = @request.allocate_id
203
+ @request.increment_pending
204
+
205
+ json = JSON.generate(metadata)
206
+ row = RowEmitter.import(id, json)
207
+ @request.completed_import_chunks << row
208
+
209
+ lazy_ref = "$L#{id.to_s(16)}"
210
+ @request.written_objects[ref] = lazy_ref
211
+ lazy_ref
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "flight/react_element"
4
+ require_relative "flight/request"
5
+ require_relative "flight/row_emitter"
6
+ require_relative "flight/serializer"
7
+ require_relative "flight/renderer"
8
+
9
+ module Ruact
10
+ module Flight
11
+ end
12
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Ruact
6
+ # Converts an HTML string (ERB output) into a ReactElement tree.
7
+ #
8
+ # Rules:
9
+ # - HTML attributes → React equivalents (class→className, for→htmlFor, etc.)
10
+ # - data-react-key="x" → becomes the React key on the element
11
+ # - HTML comments matching __RSC_N__ tokens → replaced by client component refs
12
+ # - Text nodes → plain Ruby strings
13
+ # - Multiple root nodes → wrapped in a Fragment (array)
14
+ class HtmlConverter
15
+ # HTML attribute → React prop name mapping
16
+ HTML_TO_REACT = {
17
+ "class" => "className",
18
+ "for" => "htmlFor",
19
+ "tabindex" => "tabIndex",
20
+ "readonly" => "readOnly",
21
+ "maxlength" => "maxLength",
22
+ "cellpadding" => "cellPadding",
23
+ "cellspacing" => "cellSpacing",
24
+ "rowspan" => "rowSpan",
25
+ "colspan" => "colSpan",
26
+ "crossorigin" => "crossOrigin",
27
+ "autocomplete" => "autoComplete",
28
+ "autofocus" => "autoFocus",
29
+ "accesskey" => "accessKey",
30
+ "contenteditable" => "contentEditable",
31
+ "enctype" => "encType",
32
+ "formaction" => "formAction",
33
+ "novalidate" => "noValidate",
34
+ "spellcheck" => "spellCheck"
35
+ }.freeze
36
+
37
+ # Convert an HTML string into a ReactElement tree.
38
+ # component_registry is an array of { token:, name:, ref: ClientReference, props: Hash }
39
+ def self.convert(html, component_registry = [])
40
+ new(component_registry).convert(html)
41
+ end
42
+
43
+ def initialize(component_registry)
44
+ @registry = component_registry
45
+ end
46
+
47
+ def convert(html)
48
+ # Wrap in a fragment container so Nokogiri gives us a consistent root.
49
+ # Use HTML4 fragment parser (universally available, no libgumbo needed).
50
+ doc = Nokogiri::HTML::DocumentFragment.parse(html)
51
+ children = doc.children.reject { |n| ignorable?(n) }.filter_map { |n| convert_node(n) }
52
+
53
+ case children.length
54
+ when 0 then nil
55
+ when 1 then children.first
56
+ else children # Fragment: array of elements
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def ignorable?(node)
63
+ node.text? && node.text.strip.empty?
64
+ end
65
+
66
+ def convert_node(node)
67
+ case node.type
68
+ when Nokogiri::XML::Node::TEXT_NODE
69
+ text = node.text
70
+ text.strip.empty? ? nil : text
71
+
72
+ when Nokogiri::XML::Node::COMMENT_NODE
73
+ # Check if this is an RSC component placeholder
74
+ token = node.content.strip
75
+ entry = @registry.find { |c| c[:token] == token }
76
+ return nil unless entry
77
+
78
+ Flight::ReactElement.new(
79
+ type: entry[:ref],
80
+ key: nil,
81
+ props: entry[:props]
82
+ )
83
+
84
+ when Nokogiri::XML::Node::ELEMENT_NODE
85
+ convert_element(node)
86
+
87
+ end
88
+ end
89
+
90
+ def convert_element(node)
91
+ tag = node.name.downcase
92
+
93
+ # Special element: <rsc-suspense> → SuspenseElement (Suspense boundary)
94
+ if tag == "rsc-suspense"
95
+ fallback_text = node["data-rsc-fallback"] || ""
96
+ fallback = if fallback_text.empty?
97
+ nil
98
+ else
99
+ Flight::ReactElement.new(type: "p", key: nil, props: { "children" => fallback_text })
100
+ end
101
+
102
+ child_nodes = node.children.reject { |n| ignorable?(n) }.filter_map { |n| convert_node(n) }
103
+ children = child_nodes.length == 1 ? child_nodes.first : child_nodes
104
+
105
+ return Flight::SuspenseElement.new(fallback: fallback, children: children)
106
+ end
107
+
108
+ key = node["data-react-key"]
109
+ props = {}
110
+
111
+ node.attributes.each do |attr_name, attr_node|
112
+ next if attr_name == "data-react-key"
113
+
114
+ react_name = if attr_name == "value" && %w[textarea select].include?(tag)
115
+ "defaultValue"
116
+ elsif attr_name == "value" && tag == "input"
117
+ input_type = node["type"]&.downcase
118
+ %w[submit reset button image].include?(input_type) ? "value" : "defaultValue"
119
+ elsif attr_name == "checked" && tag == "input"
120
+ "defaultChecked"
121
+ else
122
+ HTML_TO_REACT[attr_name] || camel_case_data(attr_name)
123
+ end
124
+ props[react_name] = attr_name == "style" ? parse_style(attr_node.value) : attr_node.value
125
+ end
126
+
127
+ children = node.children.reject { |n| ignorable?(n) }.filter_map { |n| convert_node(n) }
128
+
129
+ unless children.empty?
130
+ props["children"] = children.length == 1 ? children.first : children
131
+ end
132
+
133
+ Flight::ReactElement.new(type: tag, key: key, props: props)
134
+ end
135
+
136
+ # Converts a CSS inline style string into a React-compatible hash with camelCase keys.
137
+ # e.g. "font-size:16px;color:red" → {"fontSize" => "16px", "color" => "red"}
138
+ def parse_style(css_string)
139
+ css_string.split(";").each_with_object({}) do |decl, hash|
140
+ prop, _, value = decl.partition(":")
141
+ prop = prop.strip
142
+ value = value.strip
143
+ next if prop.empty? || value.empty?
144
+
145
+ camel = prop.split("-").each_with_index.map { |part, i| i.zero? ? part : part.capitalize }.join
146
+ hash[camel] = value
147
+ end
148
+ end
149
+
150
+ # data-foo-bar → "data-foo-bar" (kept as-is; React accepts kebab data attrs)
151
+ # other kebab attrs not in the map → camelCase
152
+ def camel_case_data(name)
153
+ return name if name.start_with?("data-", "aria-")
154
+
155
+ parts = name.split("-")
156
+ parts.first + parts[1..].map(&:capitalize).join
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+
5
+ module Ruact
6
+ class Railtie < Rails::Railtie
7
+ initializer "ruact.load_controller" do
8
+ require_relative "controller"
9
+ end
10
+
11
+ rake_tasks do
12
+ load File.expand_path("../tasks/rsc.rake", __dir__)
13
+ end
14
+
15
+ # Load the client manifest at boot (and on each code reload in development).
16
+ # config.to_prepare runs once in production and before every code reload in
17
+ # development, ensuring the manifest is always current without file I/O per
18
+ # request.
19
+ #
20
+ # Missing manifest behaviour (AC#5, #6):
21
+ # - development: logs a [ruact] warning; app starts normally
22
+ # - production: raises ManifestError; app does not start
23
+ #
24
+ # Also registers ActionView integration:
25
+ # - ViewHelper provides __rsc_component__ in every view context
26
+ # - ErbPreprocessorHook applies the RSC preprocessor to all ERB templates
27
+ # (layouts, views, partials) transparently via prepend.
28
+ config.to_prepare do
29
+ manifest_path = Ruact.config.manifest_path ||
30
+ Rails.root.join("public", "react-client-manifest.json")
31
+ manifest_path = Pathname.new(manifest_path) unless manifest_path.respond_to?(:exist?)
32
+
33
+ if manifest_path.exist?
34
+ Ruact.manifest = Ruact::ClientManifest.load(manifest_path)
35
+ else
36
+ Ruact::Railtie.check_manifest!(manifest_path)
37
+ end
38
+
39
+ require_relative "view_helper"
40
+ require_relative "erb_preprocessor_hook"
41
+ ActionView::Base.include(Ruact::ViewHelper)
42
+ ActionView::Template::Handlers::ERB.prepend(Ruact::ErbPreprocessorHook)
43
+ end
44
+
45
+ # Detect streaming capability at boot and log the active mode (AC#1–3).
46
+ # Also warns in development if the Vite dev server is not running (AC#4, #7).
47
+ config.after_initialize do
48
+ Ruact::Railtie.detect_streaming_mode!
49
+ next unless Rails.env.development?
50
+
51
+ Ruact::Railtie.check_vite!
52
+ end
53
+
54
+ # Detects the web server at boot, stores the streaming mode, and logs the result (AC#1–3).
55
+ # Detection is constant-based (zero I/O): Puma → enabled, Unicorn/Passenger → buffered,
56
+ # unknown → buffered (safe mode).
57
+ def self.detect_streaming_mode!
58
+ mode, label = if defined?(::Puma::Server)
59
+ [:enabled, "Puma detected"]
60
+ elsif defined?(::Falcon::Server)
61
+ [:enabled, "Falcon detected"]
62
+ elsif defined?(::Unicorn)
63
+ [:buffered, "Unicorn detected"]
64
+ elsif defined?(::PhusionPassenger)
65
+ [:buffered, "Passenger detected"]
66
+ else
67
+ [:buffered, "server unknown — defaulting to safe mode"]
68
+ end
69
+
70
+ Ruact.streaming_mode = mode
71
+ verb = mode == :enabled ? "enabled" : "buffered"
72
+ Rails.logger.info "[ruact] streaming: #{verb} (#{label})"
73
+ mode
74
+ end
75
+
76
+ # Checks whether the Vite dev server is accessible and warns if not (AC#4).
77
+ # Extracted as a class method for direct testability without a full Rails app.
78
+ def self.check_vite!
79
+ require "socket"
80
+ TCPSocket.new("localhost", 5173).close
81
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
82
+ Rails.logger.warn "[ruact] Vite dev server not detected at localhost:5173 " \
83
+ "— run npm run dev for HMR"
84
+ end
85
+
86
+ # Checks whether the manifest exists and either warns (dev) or raises (prod).
87
+ # Extracted as a class method for direct testability without a full Rails app.
88
+ def self.check_manifest!(manifest_path)
89
+ if Rails.env.production?
90
+ raise ManifestError,
91
+ "react-client-manifest.json not found — run vite build before deploying"
92
+ else
93
+ Rails.logger.warn "[ruact] react-client-manifest.json not found at " \
94
+ "#{manifest_path} — RSC rendering will be unavailable. " \
95
+ "Run 'npm run build' or start the Vite dev server."
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module Ruact
6
+ # Orchestrates the full server component render:
7
+ # ERB source → (preprocessor) → evaluated HTML → (HtmlConverter) → ReactElement tree
8
+ # → (Flight::Renderer) → wire bytes
9
+ #
10
+ # Two entry points:
11
+ # call/stream — full pipeline from ERB source (used in unit tests and legacy path)
12
+ # from_html — takes pre-rendered HTML from ActionView (used by Controller#rsc_render)
13
+ class RenderPipeline
14
+ def initialize(manifest, controller_path: nil, logger: nil)
15
+ @manifest = manifest
16
+ @controller_path = controller_path
17
+ @logger = logger
18
+ end
19
+
20
+ # Render ERB source within a given binding, return Flight wire format string.
21
+ # Deferred chunk delays are skipped — suitable for buffered responses (HTML shell).
22
+ def call(erb_source, binding_context)
23
+ _stream(erb_source, binding_context, streaming: false).to_a.join
24
+ end
25
+
26
+ # Render ERB source and return an Enumerator that yields Flight rows one at a time.
27
+ # Deferred chunk delays ARE applied — suitable for ActionController::Live streaming.
28
+ def stream(erb_source, binding_context)
29
+ _stream(erb_source, binding_context, streaming: true)
30
+ end
31
+
32
+ # Convert pre-rendered HTML (from ActionView) to Flight wire rows.
33
+ #
34
+ # IMPORTANT — Eager registry capture: ComponentRegistry.components is read
35
+ # immediately when this method is called, before the Enumerator is returned.
36
+ # This allows the caller to call ComponentRegistry.reset right after from_html
37
+ # returns (inside an ensure block) without affecting the captured registry.
38
+ #
39
+ # The returned Enumerator does NOT reference ComponentRegistry at all —
40
+ # only the eagerly-captured +registry+ local variable.
41
+ def from_html(html, streaming: false)
42
+ registry = ComponentRegistry.components.map do |entry|
43
+ ref = @manifest.reference_for(entry[:name], controller_path: @controller_path)
44
+ { token: entry[:token], name: entry[:name], ref: ref, props: entry[:props] }
45
+ end
46
+ strict = Ruact.config.strict_serialization
47
+ warning_cb = as_json_warning_callback
48
+
49
+ Enumerator.new do |y|
50
+ root_element = HtmlConverter.convert(html, registry)
51
+ Flight::Renderer.each(root_element, @manifest,
52
+ strict_serialization: strict,
53
+ on_as_json_warning: warning_cb,
54
+ streaming: streaming) { |row| y << row }
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def _stream(erb_source, binding_context, streaming: false)
61
+ Enumerator.new do |y|
62
+ ComponentRegistry.start
63
+ begin
64
+ transformed = ErbPreprocessor.transform(erb_source)
65
+ inject_helper!(binding_context)
66
+ html = ERB.new(transformed).result(binding_context)
67
+
68
+ registry = ComponentRegistry.components.map do |entry|
69
+ ref = @manifest.reference_for(entry[:name], controller_path: @controller_path)
70
+ { token: entry[:token], name: entry[:name], ref: ref, props: entry[:props] }
71
+ end
72
+
73
+ root_element = HtmlConverter.convert(html, registry)
74
+
75
+ Flight::Renderer.each(root_element, @manifest,
76
+ strict_serialization: Ruact.config.strict_serialization,
77
+ on_as_json_warning: as_json_warning_callback,
78
+ streaming: streaming) { |row| y << row }
79
+ ensure
80
+ ComponentRegistry.reset
81
+ end
82
+ end
83
+ end
84
+
85
+ def as_json_warning_callback
86
+ return nil if @logger.nil?
87
+
88
+ lambda do |class_name, attrs|
89
+ @logger.warn(
90
+ "[ruact] WARNING: #{class_name} serialized via as_json — " \
91
+ "ALL attributes exposed to client: #{attrs}. " \
92
+ "Use `include Ruact::Serializable` with `rsc_props` for explicit control"
93
+ )
94
+ end
95
+ end
96
+
97
+ # Define __rsc_component__ in the ERB binding so it can be called.
98
+ def inject_helper!(binding_context)
99
+ binding_context.eval(<<~RUBY)
100
+ def __rsc_component__(name, props = {})
101
+ token = ::Ruact::ComponentRegistry.register(name, props)
102
+ "<!-- \#{token} -->"
103
+ end
104
+ RUBY
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ # Include this module in any Ruby object you want to pass as a prop to a
5
+ # client component. Declare which attributes are safe to serialize with
6
+ # +rsc_props+; only those attributes will be included in the wire payload.
7
+ #
8
+ # @example
9
+ # class Post
10
+ # include Ruact::Serializable
11
+ # attr_reader :id, :title, :secret
12
+ # rsc_props :id, :title # :secret is never sent to the client
13
+ # end
14
+ module Serializable
15
+ def self.included(base)
16
+ base.extend(ClassMethods)
17
+ base.instance_variable_set(:@rsc_props, [])
18
+ end
19
+
20
+ module ClassMethods
21
+ # Declare which instance methods should be included in the serialized
22
+ # payload. Raises +ArgumentError+ at class-load time if any name has no
23
+ # corresponding method defined on the class.
24
+ #
25
+ # @param attrs [Array<Symbol>]
26
+ def rsc_props(*attrs)
27
+ attrs.each do |attr|
28
+ unless method_defined?(attr)
29
+ raise ArgumentError,
30
+ "rsc_props: method `#{attr}` is not defined on #{self}"
31
+ end
32
+ end
33
+ @rsc_props = attrs
34
+ end
35
+
36
+ # Returns the list of declared prop names as symbols.
37
+ # Walks the ancestor chain so subclasses inherit parent declarations.
38
+ #
39
+ # @return [Array<Symbol>]
40
+ def rsc_props_list
41
+ klass = self
42
+ while klass
43
+ return klass.instance_variable_get(:@rsc_props) if klass.instance_variable_defined?(:@rsc_props)
44
+
45
+ klass = klass.superclass
46
+ end
47
+ []
48
+ end
49
+ end
50
+
51
+ # Serialize only the attributes declared with +rsc_props+.
52
+ #
53
+ # @return [Hash{String => Object}]
54
+ def rsc_serialize
55
+ self.class.rsc_props_list.to_h { |attr| [attr.to_s, public_send(attr)] }
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ VERSION = "0.0.1"
5
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ # ActionView helper module included in ActionView::Base via Railtie.
5
+ # Provides the +__rsc_component__+ method that ERB templates call after the
6
+ # preprocessor transforms PascalCase tags into +<%= __rsc_component__(...) %>+.
7
+ #
8
+ # Thread-safe: ActionView creates a fresh view context per request, so there
9
+ # is no shared state between concurrent requests.
10
+ module ViewHelper
11
+ # Registers +name+ with +props+ in the per-request ComponentRegistry and
12
+ # returns an HTML comment placeholder that HtmlConverter later replaces with
13
+ # a ReactElement node.
14
+ #
15
+ # The returned string MUST be html_safe so ActionView does not escape the
16
+ # angle brackets — if it were escaped, HtmlConverter would not find the
17
+ # placeholder in the HTML output.
18
+ def __rsc_component__(name, props = {})
19
+ token = ComponentRegistry.register(name, props)
20
+ "<!-- #{token} -->".html_safe
21
+ end
22
+ end
23
+ end