ruact 0.0.5 → 0.0.7

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 (135) 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 +74 -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 +79 -425
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +71 -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/manifest_resolver.rb +149 -0
  41. data/lib/ruact/railtie.rb +70 -203
  42. data/lib/ruact/render_pipeline.rb +8 -1
  43. data/lib/ruact/server.rb +28 -6
  44. data/lib/ruact/server_functions/codegen.rb +49 -188
  45. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  46. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  47. data/lib/ruact/server_functions/error_payload.rb +1 -1
  48. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  49. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  50. data/lib/ruact/server_functions/query_source.rb +35 -8
  51. data/lib/ruact/server_functions/route_source.rb +3 -4
  52. data/lib/ruact/server_functions/snapshot.rb +12 -139
  53. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  54. data/lib/ruact/server_functions.rb +21 -25
  55. data/lib/ruact/signed_references.rb +162 -0
  56. data/lib/ruact/string_distance.rb +72 -0
  57. data/lib/ruact/validation_errors_collector.rb +139 -0
  58. data/lib/ruact/version.rb +1 -1
  59. data/lib/ruact/view_helper.rb +109 -0
  60. data/lib/ruact.rb +20 -19
  61. data/lib/tasks/ruact.rake +10 -53
  62. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  63. data/spec/ruact/client_manifest_spec.rb +36 -0
  64. data/spec/ruact/component_contract_spec.rb +119 -0
  65. data/spec/ruact/configuration_spec.rb +51 -34
  66. data/spec/ruact/controller_request_spec.rb +264 -0
  67. data/spec/ruact/controller_spec.rb +70 -328
  68. data/spec/ruact/doctor_spec.rb +201 -0
  69. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  70. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  71. data/spec/ruact/errors_spec.rb +0 -45
  72. data/spec/ruact/html_converter_spec.rb +50 -0
  73. data/spec/ruact/install_generator_spec.rb +591 -4
  74. data/spec/ruact/manifest_resolver_spec.rb +159 -0
  75. data/spec/ruact/query_request_spec.rb +109 -1
  76. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  77. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  78. data/spec/ruact/server_function_name_spec.rb +1 -1
  79. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  80. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  81. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  82. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  83. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  84. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  85. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  86. data/spec/ruact/server_spec.rb +8 -9
  87. data/spec/ruact/signed_references_spec.rb +164 -0
  88. data/spec/ruact/string_distance_spec.rb +38 -0
  89. data/spec/ruact/validation_errors_spec.rb +116 -0
  90. data/spec/ruact/view_helper_spec.rb +79 -0
  91. data/spec/spec_helper.rb +15 -5
  92. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
  93. data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
  94. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  95. data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
  96. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  97. data/vendor/javascript/vite-plugin-ruact/index.js +372 -6
  98. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  99. data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
  100. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  101. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  102. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  103. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  104. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  105. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  106. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  107. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  108. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  109. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  110. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  119. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  120. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  121. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  122. metadata +58 -15
  123. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  124. data/lib/ruact/server_action.rb +0 -131
  125. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  126. data/lib/ruact/server_functions/registry.rb +0 -148
  127. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  128. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  129. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  130. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  131. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  132. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  133. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  134. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  135. 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,390 +19,75 @@ 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
386
-
387
- # Returns the boot-time cached manifest (set by Railtie#config.to_prepare).
388
- # No per-request file I/O (AC#6).
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__
38
+
39
+ # Resolves the manifest for this render. In PRODUCTION this is the boot-time
40
+ # cached +Ruact.manifest+ (set by Railtie#config.to_prepare) no per-request
41
+ # I/O. In DEVELOPMENT it fetches the live manifest from the Vite dev server
42
+ # (falling back to the on-disk file, then a clear error), killing the
43
+ # boot-race where Rails read the missing file before Vite wrote it and every
44
+ # first request 500'd on +nil.reference_for+. Called once per render; the
45
+ # returned manifest is held by RenderPipeline for the whole render (not
46
+ # re-fetched per component). See {Ruact::ManifestResolver}.
389
47
  def ruact_manifest
390
- Ruact.manifest
48
+ ManifestResolver.resolve
391
49
  end
392
50
 
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.
51
+ # Only activate RSC rendering when the matching .html.erb template exists AND
52
+ # the client will accept an HTML response (AC FR26; Story 10.0).
53
+ # `request.format.html?` is false for a `*/*` wildcard (curl/bots/
54
+ # health-checks), so checking it alone routed those requests through `super`,
55
+ # which compiled+rendered the `.html.erb` outside a `ruact_render` flow and
56
+ # 500'd with "__ruact_component__ called outside a ruact_render flow".
57
+ # Checking HTML acceptability instead serves `*/*` the same HTML shell a
58
+ # browser gets. Concrete non-HTML formats (application/json, application/xml)
59
+ # carry no html/wildcard accept → still bypass to `super` so respond_to
60
+ # blocks and explicit render calls work without interference.
396
61
  def default_render
397
- if ruact_template_exists? && (request.format.html? || ruact_request?)
62
+ if ruact_template_exists? && (ruact_request? || ruact_html_acceptable?)
398
63
  ruact_render
399
64
  else
400
65
  super
401
66
  end
402
67
  end
403
68
 
69
+ # True when the client will accept an HTML response: an absent Accept (Rails
70
+ # defaults to HTML) or any acceptable type that is HTML or the `*/*` wildcard.
71
+ # `Mime::ALL.html?` is false, so the explicit `== Mime::ALL` check is required
72
+ # to recognize the wildcard (`*/*`) alongside concrete `text/html`. A nil
73
+ # entry (from an empty/unparseable Accept token) is treated as "accept
74
+ # anything" so a blank Accept degrades to HTML rather than raising.
75
+ #
76
+ # This is a MEMBERSHIP test, not a priority/quality negotiation: a mixed
77
+ # `application/json, text/html` (HTML listed alongside a concrete API format)
78
+ # activates the HTML shell, because `default_render` is only ever reached on
79
+ # an action that did NOT explicitly render — there is no JSON representation
80
+ # to prefer, and the client did say it accepts HTML. A purely concrete
81
+ # non-HTML Accept (`application/json`, `application/json, application/xml`)
82
+ # has no html/wildcard member and still bypasses to `super` (AC4).
83
+ # `Mime::Type.parse` discards `q` values, so an exotic `text/html;q=0` is
84
+ # treated as acceptable rather than as an explicit rejection — out of scope
85
+ # for the implicit-render contract (see story 10.0 deferred note).
86
+ def ruact_html_acceptable?
87
+ accepts = request.accepts
88
+ accepts.blank? || accepts.any? { |type| type.nil? || type == Mime::ALL || type.html? }
89
+ end
90
+
404
91
  # Render the RSC view for the current action using ActionView's full pipeline.
405
92
  # ActionView handles layouts, partials, and helpers — the ErbPreprocessorHook
406
93
  # ensures all PascalCase tags are transformed before template compilation.
@@ -412,6 +99,11 @@ module Ruact
412
99
  # the current action's default template.
413
100
  # +locals+: hash of local variables to pass to the template.
414
101
  def ruact_render(template: nil, locals: {})
102
+ # Story 13.3 (FR98, AC4) — seed the collector from a redirect-back flash
103
+ # before the view evaluates, so `errors={ruact_errors}` surfaces surviving
104
+ # errors (no-op on a plain render — `ruact_errors` then returns `{}`).
105
+ __ruact_read_errors_from_flash
106
+
415
107
  pipeline = RenderPipeline.new(ruact_manifest, controller_path: controller_path, logger: logger)
416
108
  streaming = ruact_request? && self.class.ancestors.include?(ActionController::Live)
417
109
 
@@ -512,6 +204,11 @@ module Ruact
512
204
  return super
513
205
  end
514
206
 
207
+ # Story 13.3 (FR98, AC4) — the Inertia "redirect back with errors" path:
208
+ # stash any `ruact_errors`-registered errors in flash so they survive this
209
+ # Flight redirect and re-render as an `errors` prop (no-op when untouched).
210
+ __ruact_stash_errors_in_flash
211
+
515
212
  render plain: "0:#{JSON.generate({ 'redirectUrl' => redirect_url, 'redirectType' => 'push' })}\n",
516
213
  content_type: "text/x-component"
517
214
  end
@@ -532,7 +229,12 @@ module Ruact
532
229
  end
533
230
 
534
231
  def ruact_html_shell(flight_payload)
535
- escaped_payload = flight_payload.gsub("</script>", '<\/script>')
232
+ # Story 14.2 — the JS asset block (entry `<script>` tags + `__FLIGHT_DATA`)
233
+ # is delegated to the single `Ruact::ViewHelper#ruact_js_assets`
234
+ # implementation. The bootstrap entry script is a deferred ES module, so it
235
+ # runs after the inline `__FLIGHT_DATA` classic script has populated the
236
+ # queue regardless of source order — emitting the whole block in `<body>`
237
+ # is correct.
536
238
  <<~HTML
537
239
  <!DOCTYPE html>
538
240
  <html lang="en">
@@ -541,16 +243,10 @@ module Ruact
541
243
  <meta name="viewport" content="width=device-width, initial-scale=1" />
542
244
  #{ruact_csrf_meta_tag}
543
245
  <title>Rails RSC</title>
544
- #{vite_tags}
545
246
  </head>
546
247
  <body>
547
248
  <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>
249
+ #{ruact_js_assets(flight_payload)}
554
250
  </body>
555
251
  </html>
556
252
  HTML
@@ -558,10 +254,10 @@ module Ruact
558
254
 
559
255
  # Story 8.3 review R7 — emits `<meta name="csrf-token" content="...">`
560
256
  # 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.
257
+ # valid `X-CSRF-Token` on every server-function (mutation) call. Without
258
+ # this, hosts that route `ruact_render` through the gem's HTML shell (the
259
+ # standard path) have no token in the document and the host's
260
+ # `protect_from_forgery` rejects every non-GET server function.
565
261
  #
566
262
  # Returns an empty string when CSRF protection isn't available
567
263
  # (non-Rails specs, or hosts that have deliberately stripped
@@ -576,47 +272,5 @@ module Ruact
576
272
  rescue StandardError
577
273
  ""
578
274
  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
275
  end
622
276
  end