ruact 0.0.1 → 0.0.3

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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +86 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1680 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +89 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +330 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +176 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +188 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +113 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +248 -0
  44. data/lib/ruact/server_functions/registry.rb +148 -0
  45. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  46. data/lib/ruact/server_functions/route_source.rb +201 -0
  47. data/lib/ruact/server_functions/snapshot.rb +195 -0
  48. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  49. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  50. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  51. data/lib/ruact/server_functions.rb +75 -0
  52. data/lib/ruact/version.rb +1 -1
  53. data/lib/ruact/view_helper.rb +17 -9
  54. data/lib/ruact.rb +85 -6
  55. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  56. data/lib/tasks/benchmark.rake +15 -11
  57. data/lib/tasks/ruact.rake +81 -0
  58. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  59. data/spec/fixtures/flight/README.md +55 -7
  60. data/spec/fixtures/flight/bigint.txt +1 -0
  61. data/spec/fixtures/flight/infinity.txt +1 -0
  62. data/spec/fixtures/flight/nan.txt +1 -0
  63. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  64. data/spec/fixtures/flight/undefined.txt +1 -0
  65. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  66. data/spec/ruact/client_manifest_spec.rb +108 -0
  67. data/spec/ruact/configuration_spec.rb +501 -0
  68. data/spec/ruact/controller_request_spec.rb +204 -0
  69. data/spec/ruact/controller_spec.rb +427 -39
  70. data/spec/ruact/doctor_spec.rb +118 -0
  71. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  72. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  73. data/spec/ruact/errors_spec.rb +95 -0
  74. data/spec/ruact/flight/renderer_spec.rb +14 -3
  75. data/spec/ruact/flight/serializer_spec.rb +129 -88
  76. data/spec/ruact/html_converter_spec.rb +183 -5
  77. data/spec/ruact/install_generator_spec.rb +93 -0
  78. data/spec/ruact/query_request_spec.rb +446 -0
  79. data/spec/ruact/query_spec.rb +105 -0
  80. data/spec/ruact/railtie_spec.rb +2 -3
  81. data/spec/ruact/render_context_spec.rb +58 -0
  82. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  83. data/spec/ruact/render_pipeline_spec.rb +784 -330
  84. data/spec/ruact/serializable_spec.rb +8 -8
  85. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  86. data/spec/ruact/server_function_name_spec.rb +53 -0
  87. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  88. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  89. data/spec/ruact/server_functions/codegen_spec.rb +429 -0
  90. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  91. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  92. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  93. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  94. data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
  95. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  96. data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
  97. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  98. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  99. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  100. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  101. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  102. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  103. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  104. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  105. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  106. data/spec/ruact/server_spec.rb +180 -0
  107. data/spec/ruact/server_upload_request_spec.rb +311 -0
  108. data/spec/ruact/view_helper_spec.rb +23 -17
  109. data/spec/spec_helper.rb +52 -1
  110. data/spec/support/fixtures/pixel.png +0 -0
  111. data/spec/support/flight_wire_parser.rb +135 -0
  112. data/spec/support/flight_wire_parser_spec.rb +93 -0
  113. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  114. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  115. data/spec/support/rails_stub.rb +75 -5
  116. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
  117. data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
  118. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
  120. data/vendor/javascript/vite-plugin-ruact/index.js +3 -2
  121. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  122. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  123. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
  124. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
  125. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
  126. metadata +87 -6
  127. data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
  128. data/lib/ruact/component_registry.rb +0 -31
  129. data/lib/tasks/rsc.rake +0 -9
data/lib/ruact/errors.rb CHANGED
@@ -11,4 +11,93 @@ module Ruact
11
11
 
12
12
  # Raised when the ERB preprocessor encounters a malformed component tag.
13
13
  class PreprocessorError < Error; end
14
+
15
+ # Raised when application code attempts to mutate Ruact::Configuration outside
16
+ # of a Ruact.configure block. The configuration is frozen after initialization
17
+ # to prevent runtime drift; see Story 7.3 for the rationale and the decision
18
+ # note for guidance on when re-configuration at runtime is appropriate.
19
+ class ConfigurationError < Error; end
20
+
21
+ # Raised by `Ruact::HtmlConverter.convert` when its input is not a `String`
22
+ # (the only accepted shape). Catches the most common upstream bug — an ERB
23
+ # template, partial, or render path that returned `nil` or a non-String value
24
+ # — at the boundary, before Nokogiri is invoked, so the failing file:line and
25
+ # the expected shape are visible at the top of the backtrace instead of
26
+ # buried under a Nokogiri stack. See Story 7.4 for the rationale.
27
+ class HtmlConverterError < Error; end
28
+
29
+ # Story 8.3 — raised by a standalone server-action block (a module that
30
+ # `extend`s {Ruact::ServerAction}) when its body invokes
31
+ # {Ruact::ServerFunctions::StandaloneContext#current_user} but no
32
+ # {Ruact::Configuration#current_user_resolver} has been configured. The
33
+ # message names both worked examples (Devise + hand-rolled session) so
34
+ # the developer can wire the resolver without leaving the stack trace.
35
+ class CurrentUserNotConfiguredError < Error
36
+ DEFAULT_MESSAGE = "Ruact.current_user requires Ruact.config.current_user_resolver to be set. " \
37
+ "Example (Devise): Ruact.configure { |c| c.current_user_resolver = ->(env) { env['warden']&.user } }. " \
38
+ "Example (hand-rolled session): Ruact.configure { |c| c.current_user_resolver = " \
39
+ "->(env) { User.find_by(id: env['rack.session'][:user_id]) } }."
40
+
41
+ def initialize(message = DEFAULT_MESSAGE)
42
+ super
43
+ end
44
+ end
45
+
46
+ # Story 8.3 — raised inside a standalone server-action block to surface a
47
+ # non-2xx response without calling `render` (which the StandaloneContext
48
+ # does not expose). The endpoint dispatcher rescues this exception class
49
+ # and renders `status` + `body` verbatim, mirroring how a controller-hosted
50
+ # action would call `render(json: ..., status: ...)`.
51
+ class ActionError < Error
52
+ attr_reader :status, :body
53
+
54
+ # @param status [Symbol, Integer] HTTP status (e.g. :unprocessable_entity, 422).
55
+ # @param body [Object] the response payload. Hash/Array/scalar values are
56
+ # rendered as JSON; nil renders an empty body.
57
+ # @param message [String] optional message; defaults to "ruact action error
58
+ # (status=<status>)" so the exception is still legible in logs.
59
+ def initialize(status:, body: nil, message: nil)
60
+ @status = status
61
+ @body = body
62
+ super(message || "ruact action error (status=#{status.inspect})")
63
+ end
64
+ end
65
+
66
+ # Story 8.5 — raised by `EndpointController#__ruact_enforce_upload_limit!`
67
+ # when an inbound multipart / urlencoded request's `Content-Length` exceeds
68
+ # `Ruact.config.max_upload_bytes`. The exception inherits from
69
+ # `Ruact::Error` so the Story 8.4 `rescue_from StandardError` chain on
70
+ # `EndpointController` catches it cleanly; the endpoint controller's
71
+ # `__ruact_status_for` maps it to HTTP 413, and `ErrorPayload.build`
72
+ # extracts the `received_bytes` / `limit_bytes` pair into a dev-only
73
+ # `upload_limit` block alongside the four baseline keys.
74
+ #
75
+ # The pair is exposed as `attr_reader` so the structured payload (and host
76
+ # log lines) can show both numbers without re-parsing the message string.
77
+ # Numbers report the WIRE `Content-Length` (which includes multipart
78
+ # boundary overhead — a 9.5 MB file uploaded via multipart will report a
79
+ # `received_bytes` slightly larger than the file size), not the parsed
80
+ # file size — that's what the guard checks, and reporting the same number
81
+ # avoids "why does the error say 9.7 MB when my file is 9.5 MB?" surprise.
82
+ #
83
+ # @example Construction shape (matches how the guard raises)
84
+ # raise Ruact::UploadTooLargeError.new(
85
+ # received_bytes: request.content_length,
86
+ # limit_bytes: Ruact.config.max_upload_bytes
87
+ # )
88
+ class UploadTooLargeError < Error
89
+ attr_reader :received_bytes, :limit_bytes
90
+
91
+ # @param received_bytes [Integer] wire `Content-Length` of the rejected request.
92
+ # @param limit_bytes [Integer] configured `Ruact.config.max_upload_bytes` at reject time.
93
+ # @param message [String] optional override; defaults to a string that
94
+ # names both numbers so the exception is legible without consulting
95
+ # the attr_readers.
96
+ def initialize(received_bytes:, limit_bytes:, message: nil)
97
+ @received_bytes = received_bytes
98
+ @limit_bytes = limit_bytes
99
+ super(message || "Upload exceeded the configured size limit " \
100
+ "(received_bytes=#{received_bytes}, limit_bytes=#{limit_bytes})")
101
+ end
102
+ end
14
103
  end
@@ -166,7 +166,7 @@ module Ruact
166
166
  # --- Serializable (explicit opt-in, no warning) ---
167
167
 
168
168
  def serialize_serializable(value)
169
- serialize_model(value.rsc_serialize)
169
+ serialize_model(value.ruact_serialize)
170
170
  end
171
171
 
172
172
  # --- as_json fallback (strict_serialization: false only) ---
@@ -182,7 +182,7 @@ module Ruact
182
182
  if data.equal?(value)
183
183
  raise Ruact::SerializationError,
184
184
  "#{value.class.name}#as_json returned self — would cause infinite recursion. " \
185
- "Include Ruact::Serializable and declare rsc_props instead"
185
+ "Include Ruact::Serializable and declare ruact_props instead"
186
186
  end
187
187
 
188
188
  attr_names = data.is_a?(Hash) ? data.keys.join(", ") : ""
@@ -8,9 +8,10 @@ module Ruact
8
8
  # Rules:
9
9
  # - HTML attributes → React equivalents (class→className, for→htmlFor, etc.)
10
10
  # - data-react-key="x" → becomes the React key on the element
11
- # - HTML comments matching __RSC_N__ tokens → replaced by client component refs
11
+ # - HTML comments matching __RUACT_N__ tokens → replaced by client component refs
12
12
  # - Text nodes → plain Ruby strings
13
13
  # - Multiple root nodes → wrapped in a Fragment (array)
14
+ # rubocop:disable Metrics/ClassLength
14
15
  class HtmlConverter
15
16
  # HTML attribute → React prop name mapping
16
17
  HTML_TO_REACT = {
@@ -34,8 +35,18 @@ module Ruact
34
35
  "spellcheck" => "spellCheck"
35
36
  }.freeze
36
37
 
38
+ # Tags whose `value` attribute maps to React's `defaultValue` (uncontrolled).
39
+ DEFAULT_VALUE_TAGS = %w[textarea select].freeze
40
+
41
+ # <input type=...> values for which `value` keeps its name in React (button-like inputs).
42
+ INPUT_BUTTON_TYPES = %w[submit reset button image].freeze
43
+
37
44
  # Convert an HTML string into a ReactElement tree.
38
45
  # component_registry is an array of { token:, name:, ref: ClientReference, props: Hash }
46
+ #
47
+ # Raises +Ruact::HtmlConverterError+ if +html+ is not a +String+. The
48
+ # validation runs before Nokogiri is invoked, so the caller's file:line
49
+ # appears at the top of the backtrace rather than Nokogiri internals.
39
50
  def self.convert(html, component_registry = [])
40
51
  new(component_registry).convert(html)
41
52
  end
@@ -44,7 +55,14 @@ module Ruact
44
55
  @registry = component_registry
45
56
  end
46
57
 
58
+ # Convert an HTML string into a ReactElement tree (instance form).
59
+ #
60
+ # Raises +Ruact::HtmlConverterError+ if +html+ is not a +String+. The
61
+ # validation runs before Nokogiri is invoked, so the caller's file:line
62
+ # appears at the top of the backtrace rather than Nokogiri internals.
47
63
  def convert(html)
64
+ validate_html_input!(html)
65
+
48
66
  # Wrap in a fragment container so Nokogiri gives us a consistent root.
49
67
  # Use HTML4 fragment parser (universally available, no libgumbo needed).
50
68
  doc = Nokogiri::HTML::DocumentFragment.parse(html)
@@ -59,6 +77,80 @@ module Ruact
59
77
 
60
78
  private
61
79
 
80
+ # Story 7.4: validate the +html+ input at the boundary so that a stray nil
81
+ # or non-String value produces a clear ruact-named error pointing at the
82
+ # caller's file:line instead of a Nokogiri stack trace.
83
+ #
84
+ # +String === html+ is used instead of +html.is_a?(String)+ because it
85
+ # delegates to +Module#===+ on +String+ rather than calling a method on
86
+ # +html+. This means even +BasicObject+ instances (which lack +is_a?+,
87
+ # +kind_of?+, and +.class+) raise +Ruact::HtmlConverterError+ here instead
88
+ # of +NoMethodError+. The message-building helper rescues every method
89
+ # call on +html+ for the same reason.
90
+ def validate_html_input!(html)
91
+ return if String === html # rubocop:disable Style/CaseEquality
92
+
93
+ # Walk the stack until we leave html_converter.rb — handles both the
94
+ # class form (HtmlConverter.convert → instance #convert) and the
95
+ # instance form, so the message always points at the application caller.
96
+ gem_file = __FILE__
97
+ stack = caller_locations(1, 10) || []
98
+ app_frames = stack.drop_while { |loc| loc.path == gem_file }
99
+ location = app_frames.first || stack.first
100
+
101
+ message = build_input_error_message(html, location)
102
+ app_backtrace = app_frames.map { |loc| "#{loc.path}:#{loc.lineno}:in `#{loc.label}'" }
103
+ app_backtrace = caller(1) if app_backtrace.empty?
104
+ raise Ruact::HtmlConverterError, message, app_backtrace
105
+ end
106
+
107
+ def build_input_error_message(value, location)
108
+ called_from = "Called from: #{location.path}:#{location.lineno}"
109
+ doc_pointer = "See HtmlConverter.convert documentation for the canonical contract."
110
+
111
+ # +nil.equal?(value)+ instead of +value.nil?+: BasicObject has no
112
+ # +nil?+ method, so identity comparison through nil's side is the
113
+ # only safe path.
114
+ if nil.equal?(value)
115
+ <<~MSG.strip
116
+ ruact: HtmlConverter.convert received nil; expected a String of HTML.
117
+ Most likely cause: an ERB template, partial, or render path returned nil instead of an HTML string. Check the call site for a missing yield, an empty respond_to branch, or a partial that returned nil.
118
+ #{called_from}
119
+ #{doc_pointer}
120
+ MSG
121
+ else
122
+ klass_name = safe_class_name(value)
123
+ preview = safe_inspect_preview(value)
124
+ <<~MSG.strip
125
+ ruact: HtmlConverter.convert expected a String of HTML; got #{klass_name}.
126
+ Received: #{preview}
127
+ #{called_from}
128
+ #{doc_pointer}
129
+ MSG
130
+ end
131
+ end
132
+
133
+ # Rescue around +.class+ so +BasicObject+ instances (and anything else
134
+ # that overrides or lacks +.class+) still produce a readable message.
135
+ def safe_class_name(value)
136
+ value.class.to_s
137
+ rescue StandardError
138
+ "an unknown object"
139
+ end
140
+
141
+ # Truncate +inspect+ to 80 chars (preview window). Rescue any exception
142
+ # raised by a hostile +inspect+ so the validation never propagates a
143
+ # third-party error class instead of +Ruact::HtmlConverterError+.
144
+ def safe_inspect_preview(value)
145
+ raw = value.inspect
146
+ raw = raw.to_s
147
+ truncated = raw[0, 80]
148
+ truncated += "..." if raw.length > 80
149
+ truncated
150
+ rescue StandardError
151
+ "<inspect raised>"
152
+ end
153
+
62
154
  def ignorable?(node)
63
155
  node.text? && node.text.strip.empty?
64
156
  end
@@ -70,7 +162,7 @@ module Ruact
70
162
  text.strip.empty? ? nil : text
71
163
 
72
164
  when Nokogiri::XML::Node::COMMENT_NODE
73
- # Check if this is an RSC component placeholder
165
+ # Check if this is a Ruact component placeholder
74
166
  token = node.content.strip
75
167
  entry = @registry.find { |c| c[:token] == token }
76
168
  return nil unless entry
@@ -89,48 +181,55 @@ module Ruact
89
181
 
90
182
  def convert_element(node)
91
183
  tag = node.name.downcase
184
+ return convert_suspense_element(node) if tag == "ruact-suspense"
92
185
 
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
186
+ props = build_props(node, tag)
187
+ children = convert_children(node)
188
+ props["children"] = children.length == 1 ? children.first : children unless children.empty?
101
189
 
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
190
+ Flight::ReactElement.new(type: tag, key: node["data-react-key"], props: props)
191
+ end
104
192
 
105
- return Flight::SuspenseElement.new(fallback: fallback, children: children)
106
- end
193
+ def convert_suspense_element(node)
194
+ fallback_text = node["data-ruact-fallback"] || ""
195
+ fallback = if fallback_text.empty?
196
+ nil
197
+ else
198
+ Flight::ReactElement.new(type: "p", key: nil, props: { "children" => fallback_text })
199
+ end
107
200
 
108
- key = node["data-react-key"]
109
- props = {}
201
+ child_nodes = convert_children(node)
202
+ children = child_nodes.length == 1 ? child_nodes.first : child_nodes
203
+
204
+ Flight::SuspenseElement.new(fallback: fallback, children: children)
205
+ end
110
206
 
207
+ def convert_children(node)
208
+ node.children.reject { |n| ignorable?(n) }.filter_map { |n| convert_node(n) }
209
+ end
210
+
211
+ def build_props(node, tag)
212
+ props = {}
111
213
  node.attributes.each do |attr_name, attr_node|
112
214
  next if attr_name == "data-react-key"
113
215
 
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
216
+ react_name = react_attr_name(attr_name, tag, node)
124
217
  props[react_name] = attr_name == "style" ? parse_style(attr_node.value) : attr_node.value
125
218
  end
219
+ props
220
+ end
126
221
 
127
- children = node.children.reject { |n| ignorable?(n) }.filter_map { |n| convert_node(n) }
222
+ def react_attr_name(attr_name, tag, node)
223
+ return "defaultValue" if attr_name == "value" && DEFAULT_VALUE_TAGS.include?(tag)
224
+ return input_value_name(node) if attr_name == "value" && tag == "input"
225
+ return "defaultChecked" if attr_name == "checked" && tag == "input"
128
226
 
129
- unless children.empty?
130
- props["children"] = children.length == 1 ? children.first : children
131
- end
227
+ HTML_TO_REACT[attr_name] || camel_case_data(attr_name)
228
+ end
132
229
 
133
- Flight::ReactElement.new(type: tag, key: key, props: props)
230
+ def input_value_name(node)
231
+ input_type = node["type"]&.downcase
232
+ INPUT_BUTTON_TYPES.include?(input_type) ? "value" : "defaultValue"
134
233
  end
135
234
 
136
235
  # Converts a CSS inline style string into a React-compatible hash with camelCase keys.
@@ -156,4 +255,5 @@ module Ruact
156
255
  parts.first + parts[1..].map(&:capitalize).join
157
256
  end
158
257
  end
258
+ # rubocop:enable Metrics/ClassLength
159
259
  end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ # Story 9.4 (route-driven redesign, Phase B) — base class for v2 server
5
+ # QUERIES. Queries are plain classes under `app/queries/`; each public
6
+ # instance method defined directly on the subclass is one query:
7
+ #
8
+ # # app/queries/application_query.rb
9
+ # class ApplicationQuery < Ruact::Query; end
10
+ #
11
+ # # app/queries/catalog_query.rb
12
+ # class CatalogQuery < ApplicationQuery
13
+ # def categories
14
+ # Category.active.pluck(:id, :name).map { |id, name| { value: id, label: name } }
15
+ # end
16
+ #
17
+ # def my_categories
18
+ # current_user.categories.pluck(:id, :name)
19
+ # end
20
+ # end
21
+ #
22
+ # # config/routes.rb
23
+ # Rails.application.routes.draw do
24
+ # ruact_queries CatalogQuery # GET /q/categories, GET /q/myCategories — visible in `rails routes`
25
+ # end
26
+ #
27
+ # Per the 2026-06-02 ADR addendum (Decision 2), dispatch goes through an
28
+ # internal gem controller that inherits `Ruact.config.query_parent_controller`
29
+ # (default `ApplicationController`) — the host's REAL callback chain
30
+ # (`authenticate_user!`, tenant scoping, Pundit) runs BEFORE the query class
31
+ # is instantiated (FR89). The query instance is fresh per request (NFR8) and
32
+ # receives its execution context via the constructor, so
33
+ # `CatalogQuery.new(fake_context).categories` is unit-testable with no Rails
34
+ # boot.
35
+ #
36
+ # The context accessors below are defined on `Ruact::Query` itself, so they
37
+ # are INHERITED by subclasses — they never appear in a subclass's
38
+ # `public_instance_methods(false)`, which is exactly the set the
39
+ # `ruact_queries` routing macro mounts (one named GET route per method).
40
+ class Query
41
+ # @param context [#current_user, #params, #request, #session] the
42
+ # per-request execution context. In production this is a
43
+ # {Ruact::ServerFunctions::QueryContext} wrapping the dispatching
44
+ # controller; in unit tests any object exposing the four readers works.
45
+ def initialize(context)
46
+ @__ruact_context = context
47
+ end
48
+
49
+ # @return [Object, nil] the host's authenticated user, via the dispatching
50
+ # controller's own `current_user` (Devise / Pundit / hand-rolled).
51
+ def current_user
52
+ @__ruact_context.current_user
53
+ end
54
+
55
+ # @return [Object] the request params (query-string parameters on a GET).
56
+ def params
57
+ @__ruact_context.params
58
+ end
59
+
60
+ # @return [Object] the live request.
61
+ def request
62
+ @__ruact_context.request
63
+ end
64
+
65
+ # @return [Object] the host middleware's session.
66
+ def session
67
+ @__ruact_context.session
68
+ end
69
+
70
+ class << self
71
+ # Story 9.4 AC4 / D1 — per-query callback opt-out (resolves ADR open
72
+ # item 1). Mirrors Rails' `skip_before_action` ergonomics; the recorded
73
+ # skips are applied verbatim to THIS query class's generated dispatch
74
+ # controller when `ruact_queries` draws its routes, so the opt-out never
75
+ # leaks to other query classes:
76
+ #
77
+ # class PublicCatalogQuery < ApplicationQuery
78
+ # ruact_skip_before_action :authenticate_user!
79
+ #
80
+ # def categories = Category.pluck(:id, :name)
81
+ # end
82
+ #
83
+ # Options are forwarded to `skip_before_action` untouched — `only:` /
84
+ # `except:` scope the skip to specific query methods (each method is one
85
+ # controller action), `raise: false` tolerates a callback the parent
86
+ # does not define.
87
+ #
88
+ # @param callbacks [Array<Symbol>] callback name(s) to skip.
89
+ # @param options [Hash] forwarded to `skip_before_action` verbatim.
90
+ # @return [void]
91
+ def ruact_skip_before_action(*callbacks, **options)
92
+ __ruact_skipped_callbacks << [callbacks.map(&:to_sym), options]
93
+ nil
94
+ end
95
+
96
+ # The recorded `(callbacks, options)` pairs for this query class,
97
+ # consumed by {Ruact::ServerFunctions::QueryDispatch} when the dispatch
98
+ # controller is generated. Per-class (not inherited) — a skip describes
99
+ # the queries of the class that declares it.
100
+ #
101
+ # @return [Array<Array(Array<Symbol>, Hash)>]
102
+ def __ruact_skipped_callbacks
103
+ @__ruact_skipped_callbacks ||= []
104
+ end
105
+ end
106
+ end
107
+ end