ruact 0.0.5 → 0.0.6

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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +67 -18
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
  93. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  94. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  95. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  96. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  97. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  98. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  99. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  102. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  104. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  107. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  118. metadata +55 -15
  119. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  120. data/lib/ruact/server_action.rb +0 -131
  121. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  122. data/lib/ruact/server_functions/registry.rb +0 -148
  123. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  124. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  125. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  126. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  127. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  128. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  129. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  130. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  131. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -3,6 +3,8 @@
3
3
  require "json"
4
4
  require "socket"
5
5
  require "uri"
6
+ require_relative "view_helper"
7
+ require_relative "validation_errors_collector"
6
8
 
7
9
  module Ruact
8
10
  # Include in ApplicationController to enable RSC rendering.
@@ -17,372 +19,22 @@ module Ruact
17
19
  module Controller
18
20
  extend ActiveSupport::Concern
19
21
 
20
- # Story 8.1 review-batch 1 (2026-05-14) — symbols a host MUST NOT use
21
- # as `ruact_action` / `ruact_query` names because overriding them
22
- # would corrupt request handling. Keep sorted; documented per name:
23
- # `:params`, `:request`, `:response`, `:headers`, `:session`, `:flash`,
24
- # `:cookies`request/response accessors; overriding breaks reads
25
- # `:render`, `:redirect_to`, `:head`, `:send_file`, `:send_data`
26
- # response producers; overriding breaks the response path
27
- # `:action_name`, `:controller_name`, `:controller_path` — routing
28
- # identification; overriding breaks `before_action :foo, only:` matching
29
- # `:url_for`, `:url_options` — URL generation
30
- # `:dispatch`, `:process`, `:process_action` — Rails dispatch internals
31
- # `:form_authenticity_token`, `:verified_request?` — CSRF plumbing
32
- # Re-run-4 (2026-05-15) — list expanded with the additional
33
- # ActionController instance methods reviewers flagged as silently
34
- # clobberable: `:render_to_string` / `:render_to_body` (response
35
- # producers used by templating), `:send_action` (Rails dispatch
36
- # internal), `:logger` / `:logger=` (clobbering breaks every log
37
- # statement on the controller), `:default_render` (the gem hook
38
- # method itself — overriding would disable RSC rendering).
39
- FRAMEWORK_RESERVED_METHODS = %i[
40
- __send__ action_name controller_name controller_path cookies
41
- default_render dispatch flash form_authenticity_token head headers
42
- instance_eval instance_exec logger logger= method params process
43
- process_action protect_from_forgery public_send redirect_to render
44
- render_to_body render_to_string request response send send_action
45
- send_data send_file session skip_forgery_protection url_for
46
- url_options verified_request? verify_authenticity_token
47
- ].to_set.freeze
48
-
49
- # Story 8.1 — class-level DSL surface. The `ruact_action` macro registers
50
- # a server-callable symbol with `Ruact.action_registry` (so the Vite-plugin
51
- # codegen from Story 8.0a picks it up at the next `config.to_prepare`) AND
52
- # defines a matching public instance method that is reachable ONLY via the
53
- # gem's `POST /__ruact/fn/:name` endpoint dispatch path. The defined method
54
- # body checks a thread-local sentinel (`Thread.current[:__ruact_dispatching]`)
55
- # set by `Ruact::ServerFunctions::EndpointController#dispatch_action` and
56
- # raises `Ruact::Error` for any direct call from in-controller code, a
57
- # wildcard route, or `host_class.action(...).call` — closing the
58
- # GET-without-CSRF attack surface that a host's `get ":controller/:action"`
59
- # route would otherwise create. If a developer needs the block body from
60
- # another controller method, they should refactor the body into a PORO that
61
- # both the action and the controller call.
62
- #
63
- # Validation (naming-bridge rule + within-registry / cross-registry
64
- # collision detection) fires from {Ruact::ServerFunctions::Registry#register}
65
- # at controller-class load time — the failure is loud at boot, not at
66
- # request-dispatch time.
67
- class_methods do
68
- # @param symbol [Symbol] the action name (snake_case; bridged to JS
69
- # camelCase by {Ruact::ServerFunctions::NameBridge}).
70
- # @yield [params] the block runs via `instance_exec` on a fresh
71
- # controller instance at dispatch time; `params` shadows the request's
72
- # `params` accessor and is an `ActionController::Parameters` instance
73
- # wrapping the action-call arguments (NOT the request's query/form params).
74
- # @return [Ruact::ServerFunctions::RegistryEntry] the entry just registered.
75
- # @raise [Ruact::ConfigurationError] when the symbol fails the
76
- # naming-bridge rule or collides with another `ruact_action` in this
77
- # registry (cross-registry collisions with `ruact_query` are caught
78
- # later by {Ruact::ServerFunctions::Snapshot.functions_payload}).
79
- def ruact_action(symbol, &block)
80
- # Re-run-2 (2026-05-14) — `ruact_action` strictly requires a Symbol.
81
- # A String slips through the naming-bridge regex but stores a
82
- # String key in `@entries`, while `EndpointController#dispatch_action`
83
- # looks the entry up by `:name.to_sym` — net effect: silent 404 on
84
- # every dispatch. Refuse Strings (and anything else) loudly.
85
- unless symbol.is_a?(Symbol)
86
- raise ArgumentError,
87
- "ruact_action requires a Symbol argument, got " \
88
- "#{symbol.inspect} (#{symbol.class}). Use " \
89
- "`ruact_action :#{symbol}` not `ruact_action #{symbol.inspect}`."
90
- end
91
-
92
- unless block
93
- raise ArgumentError,
94
- "ruact_action :#{symbol} requires a block — declare the " \
95
- "implementation with `ruact_action :#{symbol} do |params| ... end`"
96
- end
97
-
98
- # Re-run-4 (2026-05-15) — parameter-shape guard (replaces the
99
- # earlier `block.arity` check). `block.arity` is negative for
100
- # ALL signatures that include any optional/keyword/splat
101
- # parameter — including footguns like `do |params, required:|`
102
- # which would crash at dispatch time when the macro invokes the
103
- # block with one positional argument and no keyword arguments.
104
- # We use `block.parameters` for full inspection: the block must
105
- # accept exactly one positional argument (`:req`, `:opt`, or
106
- # `:rest`) AND have no required keyword parameters (`:keyreq`).
107
- # Optional keyword params (`:key`) and double-splat (`:keyrest`)
108
- # are fine.
109
- # Re-run-5 (2026-05-15) — block must accept exactly one
110
- # positional argument. The macro invokes the block with one
111
- # positional arg (`instance_exec(args, &block)`), so:
112
- #
113
- # - `do |a, b|` would have `b` silently set to `nil`.
114
- # Parameters report `[[:opt, :a], [:opt, :b]]` for blocks
115
- # (proc-arity-coercion). Reject `:opt+:req > 1`.
116
- # - `do |p, required:|` (kwarg case): block.arity stays
117
- # negative, dispatch later raises. Reject `:keyreq`.
118
- # - `do |*args|` accepts any count including 1; `:rest`
119
- # entry counts as 1.
120
- # - `do |params, opt: nil|` is fine (optional kwarg).
121
- #
122
- # Allowed shapes: `do |p|`, `do |*args|`, `do |p, key: nil|`.
123
- # Rejected: `do ||`, `do |a, b|`, `do |p, required:|`.
124
- req_count = block.parameters.count { |kind, _| kind == :req }
125
- opt_count = block.parameters.count { |kind, _| kind == :opt }
126
- rest_count = block.parameters.count { |kind, _| kind == :rest }
127
- named_positional = req_count + opt_count
128
- positional_total = named_positional + rest_count
129
- has_required_kwarg = block.parameters.any? { |kind, _| kind == :keyreq }
130
- if positional_total.zero? || named_positional > 1 || has_required_kwarg
131
- raise ArgumentError,
132
- "ruact_action :#{symbol} block must accept exactly one " \
133
- "positional parameter and no required keyword arguments " \
134
- "(got parameters=#{block.parameters.inspect}). Use " \
135
- "`ruact_action :#{symbol} do |params| ... end`."
136
- end
137
-
138
- # Review-batch 1 (2026-05-14) — framework-method-clobber guard.
139
- # Refuse to define if the symbol matches one of the well-known
140
- # ActionController instance methods that would corrupt request
141
- # handling if overridden (the `:params`, `:render`, `:session`,
142
- # `:redirect_to`, `:dispatch`, etc. footgun). The hardcoded list
143
- # is the canonical set documented in the Rails Guides; it's used
144
- # in place of a dynamic `ActionController::Base.method_defined?`
145
- # check because (a) the gem can be loaded before ActionController
146
- # (e.g., in a non-Rails context) and (b) the dynamic list would
147
- # include too many low-risk inherited methods (`:object_id`,
148
- # `:respond_to?`) and produce confusing error messages.
149
- if FRAMEWORK_RESERVED_METHODS.include?(symbol)
150
- raise Ruact::ConfigurationError,
151
- "ruact_action :#{symbol} would clobber a framework method — " \
152
- "#{symbol.inspect} is a reserved ActionController instance " \
153
- "method. Pick a different symbol (e.g. :#{symbol}_action) so " \
154
- "the host's CSRF / params / render plumbing remains intact."
155
- end
156
-
157
- # Re-run-6 (2026-05-15) — also reject ANY symbol that names a method
158
- # inherited from `ActionController::Base` (or its ancestors UP TO but
159
- # NOT INCLUDING `Object`). The hardcoded `FRAMEWORK_RESERVED_METHODS`
160
- # list is the documented surface, but Rails keeps adding/renaming
161
- # internal methods (`:status`, `:send_action`, `:render_to_string`,
162
- # `:logger`, `:default_render`, …). Anything that lives on
163
- # `ActionController::Base` but NOT on plain `Object` is, by
164
- # definition, framework plumbing — overriding it is unsafe.
165
- # We carve `Object`/`Kernel`/`BasicObject` off so genuinely-generic
166
- # methods (`:object_id`, `:respond_to?`, `:hash`, `:tap`) don't
167
- # trip the guard — those are safe to coexist with as block-arg
168
- # shadow inside `instance_exec`.
169
- baseline_class = defined?(ActionController::Base) ? ActionController::Base : nil
170
- if baseline_class
171
- object_methods = Object.instance_methods + Object.private_instance_methods
172
- framework_methods = baseline_class.instance_methods + baseline_class.private_instance_methods
173
- if (framework_methods - object_methods).include?(symbol)
174
- raise Ruact::ConfigurationError,
175
- "ruact_action :#{symbol} would clobber an inherited " \
176
- "ActionController::Base method. Overriding framework " \
177
- "plumbing (`#{symbol.inspect}`) is unsafe — pick a " \
178
- "different symbol (e.g. :#{symbol}_action)."
179
- end
180
- end
181
-
182
- # Re-run-2 (2026-05-14) — refuse to clobber a method ALREADY defined
183
- # on the host controller class itself. Common case: a controller has
184
- # a normal `def index` action and the dev mistakenly writes
185
- # `ruact_action :index do ... end`. Pre-batch this silently
186
- # overrode :index with the action body + thread-local guard — the
187
- # standard `GET /widgets` would then raise the security guard, since
188
- # it's not a /__ruact/fn/index call. We check `instance_methods(false)
189
- # + private_instance_methods(false)` (own class only — inherited
190
- # framework methods are already caught by FRAMEWORK_RESERVED_METHODS).
191
- #
192
- # A method previously defined BY `ruact_action` on this same class
193
- # is NOT a clobber — re-registration is legitimate (dev-mode reload,
194
- # test re-registration after registry reset). We track our own
195
- # define_method calls in `@__ruact_defined_methods` and skip the
196
- # guard for those.
197
- @__ruact_defined_methods ||= Set.new
198
- own_methods = instance_methods(false) + private_instance_methods(false)
199
- if own_methods.include?(symbol) && !@__ruact_defined_methods.include?(symbol)
200
- raise Ruact::ConfigurationError,
201
- "ruact_action :#{symbol} would clobber an existing method " \
202
- "on #{name || self}. If #{symbol.inspect} is meant to be " \
203
- "a server action, remove the existing definition first; " \
204
- "if it's a regular controller action, pick a different " \
205
- "ruact_action symbol (e.g. :#{symbol}_remote)."
206
- end
207
-
208
- # Re-run-3 (2026-05-15) — refuse to clobber an INHERITED app helper.
209
- # Common case: `ApplicationController` defines `current_user` /
210
- # `authenticate_user!` / `authorize` and a subclass mistakenly
211
- # writes `ruact_action :current_user`. The above own-class check
212
- # misses these because the method lives on a superclass; the
213
- # FRAMEWORK_RESERVED_METHODS check misses them because they are
214
- # app-defined, not part of `ActionController::Base`. Detect by
215
- # asking the class hierarchy MINUS the ActionController baseline:
216
- # any method that responds on `self` but NOT on
217
- # `ActionController::Base` is an app-defined helper inherited
218
- # from `ApplicationController` (or a concern mixed in there).
219
- baseline = defined?(ActionController::Base) ? ActionController::Base : nil
220
- if baseline &&
221
- (method_defined?(symbol) || private_method_defined?(symbol)) &&
222
- !(baseline.method_defined?(symbol) || baseline.private_method_defined?(symbol)) &&
223
- !@__ruact_defined_methods.include?(symbol)
224
- raise Ruact::ConfigurationError,
225
- "ruact_action :#{symbol} would clobber an inherited helper " \
226
- "on #{name || self} (likely defined on " \
227
- "ApplicationController or a concern). Overriding " \
228
- "#{symbol.inspect} would break callers that rely on it — " \
229
- "pick a different ruact_action symbol (e.g. :#{symbol}_remote)."
230
- end
231
-
232
- Ruact.action_registry.register(symbol, kind: :action, controller: self, &block)
233
-
234
- # Review-batch 1 (2026-05-14) — define `<symbol>` directly (no
235
- # separate `__ruact_action_*` wrapper). This makes `before_action
236
- # :foo, only: :create_post` match the actual action name. The
237
- # method is public so `ActionController#process` dispatches to it
238
- # through the standard callback chain.
239
- #
240
- # Defense in depth: the method body raises unless invoked under the
241
- # gem's endpoint dispatch path (a thread-local sentinel set by
242
- # `Ruact::ServerFunctions::EndpointController#dispatch_action`).
243
- # Without the sentinel, a wildcard route like `get ":controller/
244
- # :action"` could otherwise reach `create_post` as a GET (no CSRF).
245
- @__ruact_defined_methods << symbol
246
-
247
- # Re-run-6 (2026-05-15) — install a `method_added` hook so a LATER
248
- # `def #{symbol}; ...; end` in the same controller body (or a reopen)
249
- # cannot silently override the macro-defined action method. Without
250
- # this guard, the registry/codegen still export the symbol but
251
- # `host_class.dispatch` would run the user's later definition,
252
- # producing a confusing 500 (the sentinel check is gone) instead of
253
- # a loud class-load-time error. The hook is installed once per class
254
- # and ignores re-definitions performed by the macro itself
255
- # (tracked via `@__ruact_being_defined_by_ruact_action`).
256
- #
257
- # Re-run-7 (2026-05-15) — install the hook by `prepend`-ing a Module
258
- # into the host's singleton class instead of `define_method`-ing
259
- # `:method_added` directly on it. `define_method` REPLACES any
260
- # `def self.method_added` (or `class << self; def method_added`)
261
- # the host or its concerns already defined; `super(meth)` would
262
- # then chain to `Module#method_added` (the default no-op), not the
263
- # original implementation — silently breaking instrumentation and
264
- # DSL bookkeeping concerns. Prepending a hook module keeps the
265
- # original `method_added` in the ancestor chain so `super(meth)`
266
- # invokes it after our check.
267
- unless @__ruact_method_added_hook_installed
268
- @__ruact_method_added_hook_installed = true
269
- ruact_class = self
270
- hook = Module.new do
271
- define_method(:method_added) do |meth|
272
- defined_set = ruact_class.instance_variable_get(:@__ruact_defined_methods)
273
- being_defined = ruact_class.instance_variable_get(:@__ruact_being_defined_by_ruact_action)
274
- if defined_set&.include?(meth) && !being_defined
275
- defined_set.delete(meth)
276
- raise Ruact::ConfigurationError,
277
- "method :#{meth} on #{ruact_class.name || ruact_class} was " \
278
- "registered by `ruact_action :#{meth}` and then re-defined " \
279
- "in the same class body. The later definition would " \
280
- "silently shadow the macro-defined action and break " \
281
- "endpoint dispatch. Either remove the explicit `def " \
282
- "#{meth}` (the macro already defines it) or rename the " \
283
- "ruact_action."
284
- end
285
- super(meth)
286
- end
287
- end
288
- singleton_class.prepend(hook)
289
- end
290
-
291
- @__ruact_being_defined_by_ruact_action = true
292
- define_method(symbol) do
293
- unless Thread.current[:__ruact_dispatching] == symbol
294
- raise Ruact::Error,
295
- "ruact action :#{symbol} can only be invoked through " \
296
- "POST /__ruact/fn/:name. Direct method calls or wildcard " \
297
- "routes are rejected for security reasons."
298
- end
299
- begin
300
- args = ruact_action_params
301
- rescue JSON::ParserError => e
302
- # Re-run-4 (2026-05-15) — return a structured 400 instead of
303
- # surfacing the raw `JSON::ParserError`. The host's
304
- # `rescue_from` chain may not have a handler for it (Rails'
305
- # default is a 500), and even when it does the response
306
- # shape is not the bad-request contract the JS runtime
307
- # expects (`{error}` JSON body + 400 status).
308
- return render(
309
- json: { error: "ruact action :#{symbol} received malformed JSON body: #{e.message}" },
310
- status: :bad_request
311
- )
312
- end
313
- result = instance_exec(args, &block)
314
- return if performed?
315
-
316
- # AC2: a nil block return renders 204 No Content (no body). A non-nil
317
- # return renders 200 + JSON.
318
- if result.nil?
319
- head(:no_content)
320
- else
321
- render(json: result)
322
- end
323
- end
324
- @__ruact_being_defined_by_ruact_action = false
325
-
326
- # ActionController caches `action_methods` lazily; clear the cache so
327
- # the newly-defined action is dispatchable in the same boot cycle
328
- # (matters in tests and in dev where `ruact_action` declarations
329
- # accumulate after the controller class first loads).
330
- clear_action_methods! if respond_to?(:clear_action_methods!, true)
331
- end
332
- end
22
+ include Ruact::ValidationErrorsCollector
23
+ # Story 14.2 (FR104) the JS asset markup (entry `<script>` tags +
24
+ # `__FLIGHT_DATA`) lives in one place: `Ruact::ViewHelper#ruact_js_assets`.
25
+ # The controller mixes the helper in so `ruact_html_shell` delegates to that
26
+ # single implementation (controller↔helper parity no duplicated tag logic),
27
+ # and so the shared `vite_dev_running?` / `vite_manifest_entry` helpers are
28
+ # reachable here without duplication.
29
+ include Ruact::ViewHelper
333
30
 
334
31
  private
335
32
 
336
- # Story 8.1 extracts the action-call arguments from the request body for
337
- # a `ruact_action` invocation. The result becomes the `params` block-arg
338
- # the dev wrote in `ruact_action :foo do |params| ... end`, shadowing the
339
- # request's own `params` accessor. Returns an `ActionController::Parameters`
340
- # so `params.require(:title).permit(...)` continues to work inside the block.
341
- #
342
- # Wire shape (from the JS runtime):
343
- # - `Content-Type: application/json` → `JSON.parse(request.body)` as a Hash
344
- # - `Content-Type: multipart/form-data` or `application/x-www-form-urlencoded`
345
- # → Rails has already parsed `request.request_parameters` for us
346
- # - Other / missing → empty Hash (callers must validate themselves)
347
- def ruact_action_params
348
- raw = ruact_action_raw_args
349
- ActionController::Parameters.new(raw)
350
- end
351
-
352
- def ruact_action_raw_args
353
- content_type = request.content_mime_type&.to_s ||
354
- request.headers["Content-Type"]&.split(";")&.first
355
- case content_type
356
- when "application/json"
357
- # Re-run-3 (2026-05-15) — use `request.raw_post` instead of
358
- # `request.body.read`. Rack caches `raw_post` after the first
359
- # read of the request body, so a host `before_action` that
360
- # already touched `request.body` would otherwise leave the
361
- # IO at EOF and our `.read` would return `""` — silently
362
- # coercing the action call to an empty hash. `raw_post` is
363
- # safe to call multiple times and returns the full POST body.
364
- body = request.raw_post
365
- return {} if body.nil? || body.empty?
366
-
367
- # Review-batch 2 (2026-05-14) — raise on malformed JSON instead of
368
- # silently coercing to {}. A request with `Content-Type:
369
- # application/json` and an unparseable body is corrupted; running
370
- # the action on `{}` would mask real client bugs. Rails' standard
371
- # 400 handler surfaces this as a clean error response.
372
- parsed = JSON.parse(body)
373
- parsed.is_a?(Hash) ? parsed : { "_value" => parsed }
374
- when "multipart/form-data", "application/x-www-form-urlencoded"
375
- # Review-batch 2 (2026-05-14) — `request.request_parameters` is the
376
- # POST body ONLY (routing params like `:name`, `:action`,
377
- # `:controller` live in `request.path_parameters`, NOT here). The
378
- # earlier `.except(:name, :action, :controller)` was a bug — it
379
- # would silently drop legitimate body fields named `name`,
380
- # `action`, or `controller` from forms.
381
- request.request_parameters
382
- else
383
- {}
384
- end
385
- end
33
+ # `ruact_js_assets` / `__ruact_component__` are public VIEW helpers, but on a
34
+ # controller a public instance method is exposed as a routable action. Demote
35
+ # the mixed-in helper methods so they are never callable as actions (they are
36
+ # only ever invoked internally by `ruact_html_shell`).
37
+ private :ruact_js_assets, :__ruact_component__
386
38
 
387
39
  # Returns the boot-time cached manifest (set by Railtie#config.to_prepare).
388
40
  # No per-request file I/O (AC#6).
@@ -390,17 +42,46 @@ module Ruact
390
42
  Ruact.manifest
391
43
  end
392
44
 
393
- # Only activate RSC rendering for HTML-like requests (AC FR26).
394
- # JSON, XML, and other formats bypass RSC entirely so respond_to blocks
395
- # and explicit render calls work without interference.
45
+ # Only activate RSC rendering when the matching .html.erb template exists AND
46
+ # the client will accept an HTML response (AC FR26; Story 10.0).
47
+ # `request.format.html?` is false for a `*/*` wildcard (curl/bots/
48
+ # health-checks), so checking it alone routed those requests through `super`,
49
+ # which compiled+rendered the `.html.erb` outside a `ruact_render` flow and
50
+ # 500'd with "__ruact_component__ called outside a ruact_render flow".
51
+ # Checking HTML acceptability instead serves `*/*` the same HTML shell a
52
+ # browser gets. Concrete non-HTML formats (application/json, application/xml)
53
+ # carry no html/wildcard accept → still bypass to `super` so respond_to
54
+ # blocks and explicit render calls work without interference.
396
55
  def default_render
397
- if ruact_template_exists? && (request.format.html? || ruact_request?)
56
+ if ruact_template_exists? && (ruact_request? || ruact_html_acceptable?)
398
57
  ruact_render
399
58
  else
400
59
  super
401
60
  end
402
61
  end
403
62
 
63
+ # True when the client will accept an HTML response: an absent Accept (Rails
64
+ # defaults to HTML) or any acceptable type that is HTML or the `*/*` wildcard.
65
+ # `Mime::ALL.html?` is false, so the explicit `== Mime::ALL` check is required
66
+ # to recognize the wildcard (`*/*`) alongside concrete `text/html`. A nil
67
+ # entry (from an empty/unparseable Accept token) is treated as "accept
68
+ # anything" so a blank Accept degrades to HTML rather than raising.
69
+ #
70
+ # This is a MEMBERSHIP test, not a priority/quality negotiation: a mixed
71
+ # `application/json, text/html` (HTML listed alongside a concrete API format)
72
+ # activates the HTML shell, because `default_render` is only ever reached on
73
+ # an action that did NOT explicitly render — there is no JSON representation
74
+ # to prefer, and the client did say it accepts HTML. A purely concrete
75
+ # non-HTML Accept (`application/json`, `application/json, application/xml`)
76
+ # has no html/wildcard member and still bypasses to `super` (AC4).
77
+ # `Mime::Type.parse` discards `q` values, so an exotic `text/html;q=0` is
78
+ # treated as acceptable rather than as an explicit rejection — out of scope
79
+ # for the implicit-render contract (see story 10.0 deferred note).
80
+ def ruact_html_acceptable?
81
+ accepts = request.accepts
82
+ accepts.blank? || accepts.any? { |type| type.nil? || type == Mime::ALL || type.html? }
83
+ end
84
+
404
85
  # Render the RSC view for the current action using ActionView's full pipeline.
405
86
  # ActionView handles layouts, partials, and helpers — the ErbPreprocessorHook
406
87
  # ensures all PascalCase tags are transformed before template compilation.
@@ -412,6 +93,11 @@ module Ruact
412
93
  # the current action's default template.
413
94
  # +locals+: hash of local variables to pass to the template.
414
95
  def ruact_render(template: nil, locals: {})
96
+ # Story 13.3 (FR98, AC4) — seed the collector from a redirect-back flash
97
+ # before the view evaluates, so `errors={ruact_errors}` surfaces surviving
98
+ # errors (no-op on a plain render — `ruact_errors` then returns `{}`).
99
+ __ruact_read_errors_from_flash
100
+
415
101
  pipeline = RenderPipeline.new(ruact_manifest, controller_path: controller_path, logger: logger)
416
102
  streaming = ruact_request? && self.class.ancestors.include?(ActionController::Live)
417
103
 
@@ -512,6 +198,11 @@ module Ruact
512
198
  return super
513
199
  end
514
200
 
201
+ # Story 13.3 (FR98, AC4) — the Inertia "redirect back with errors" path:
202
+ # stash any `ruact_errors`-registered errors in flash so they survive this
203
+ # Flight redirect and re-render as an `errors` prop (no-op when untouched).
204
+ __ruact_stash_errors_in_flash
205
+
515
206
  render plain: "0:#{JSON.generate({ 'redirectUrl' => redirect_url, 'redirectType' => 'push' })}\n",
516
207
  content_type: "text/x-component"
517
208
  end
@@ -532,7 +223,12 @@ module Ruact
532
223
  end
533
224
 
534
225
  def ruact_html_shell(flight_payload)
535
- escaped_payload = flight_payload.gsub("</script>", '<\/script>')
226
+ # Story 14.2 — the JS asset block (entry `<script>` tags + `__FLIGHT_DATA`)
227
+ # is delegated to the single `Ruact::ViewHelper#ruact_js_assets`
228
+ # implementation. The bootstrap entry script is a deferred ES module, so it
229
+ # runs after the inline `__FLIGHT_DATA` classic script has populated the
230
+ # queue regardless of source order — emitting the whole block in `<body>`
231
+ # is correct.
536
232
  <<~HTML
537
233
  <!DOCTYPE html>
538
234
  <html lang="en">
@@ -541,16 +237,10 @@ module Ruact
541
237
  <meta name="viewport" content="width=device-width, initial-scale=1" />
542
238
  #{ruact_csrf_meta_tag}
543
239
  <title>Rails RSC</title>
544
- #{vite_tags}
545
240
  </head>
546
241
  <body>
547
242
  <div id="root"></div>
548
- <script>
549
- (function() {
550
- var d = (self.__FLIGHT_DATA = self.__FLIGHT_DATA || []);
551
- d.push(#{escaped_payload.inspect});
552
- })();
553
- </script>
243
+ #{ruact_js_assets(flight_payload)}
554
244
  </body>
555
245
  </html>
556
246
  HTML
@@ -558,10 +248,10 @@ module Ruact
558
248
 
559
249
  # Story 8.3 review R7 — emits `<meta name="csrf-token" content="...">`
560
250
  # into the shell so the JS runtime's `<meta>` lookup can forward a
561
- # valid `X-CSRF-Token` on every `_makeRef` call. Without this, hosts
562
- # that route `ruact_render` through the gem's HTML shell (the
563
- # standard path) have no token in the document and standalone
564
- # actions' gem-side CSRF check (Story 8.3 AC5) always rejects.
251
+ # valid `X-CSRF-Token` on every server-function (mutation) call. Without
252
+ # this, hosts that route `ruact_render` through the gem's HTML shell (the
253
+ # standard path) have no token in the document and the host's
254
+ # `protect_from_forgery` rejects every non-GET server function.
565
255
  #
566
256
  # Returns an empty string when CSRF protection isn't available
567
257
  # (non-Rails specs, or hosts that have deliberately stripped
@@ -576,47 +266,5 @@ module Ruact
576
266
  rescue StandardError
577
267
  ""
578
268
  end
579
-
580
- def vite_tags
581
- if Rails.env.development? && vite_dev_running?
582
- # @vitejs/plugin-react normally injects this preamble by processing index.html.
583
- # Since our HTML is generated by Rails (not Vite), we inject it manually.
584
- # Without it, every JSX file throws "can't detect preamble" at runtime.
585
- react_preamble = <<~JS
586
- <script type="module">
587
- import RefreshRuntime from 'http://localhost:5173/@react-refresh';
588
- RefreshRuntime.injectIntoGlobalHook(window);
589
- window.$RefreshReg$ = () => {};
590
- window.$RefreshSig$ = () => (type) => type;
591
- window.__vite_plugin_react_preamble_installed__ = true;
592
- </script>
593
- JS
594
-
595
- react_preamble + <<~HTML
596
- <script type="module" src="http://localhost:5173/@vite/client"></script>
597
- <script type="module" src="http://localhost:5173/app/javascript/application.jsx"></script>
598
- HTML
599
- else
600
- # Production: read hashed URL from Vite manifest
601
- entry = vite_manifest_entry("app/javascript/application.jsx")
602
- src = entry ? "/assets/#{entry['file']}" : "/assets/application.js"
603
- %(<script type="module" src="#{src}"></script>)
604
- end
605
- end
606
-
607
- def vite_dev_running?
608
- require "socket"
609
- Socket.tcp("localhost", 5173, connect_timeout: 1).close
610
- true
611
- rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError
612
- false
613
- end
614
-
615
- def vite_manifest_entry(src_path)
616
- manifest_path = Rails.root.join("public", "assets", ".vite", "manifest.json")
617
- return nil unless File.exist?(manifest_path)
618
-
619
- JSON.parse(File.read(manifest_path))[src_path]
620
- end
621
269
  end
622
270
  end