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/railtie.rb CHANGED
@@ -6,10 +6,73 @@ module Ruact
6
6
  class Railtie < Rails::Railtie
7
7
  initializer "ruact.load_controller" do
8
8
  require_relative "controller"
9
+ # Story 9.1 — the v2 route-driven marker concern (`include Ruact::Server`).
10
+ require_relative "server"
11
+ require_relative "server_action"
12
+ # Story 9.4 (D8) — requiring ruact/routing installs the `ruact_queries`
13
+ # macro into ActionDispatch::Routing::Mapper (a Mapper extension, NOT a
14
+ # mounted route — the host's routes.rb explicitly mounts each query
15
+ # class). Initializers all run before the routes file is loaded, so the
16
+ # macro is in place for the first draw.
17
+ require_relative "routing"
9
18
  end
10
19
 
11
- rake_tasks do
12
- load File.expand_path("../tasks/rsc.rake", __dir__)
20
+ # Story 8.3 — register `app/server_actions/` as a Rails `paths` entry
21
+ # AND as an autoload + eager-load path so Zeitwerk discovers standalone
22
+ # `Ruact::ServerAction` modules the same way it discovers
23
+ # controllers/models/jobs. Registering via `config.paths.add` (vs.
24
+ # `config.autoload_paths <<` only) is what makes
25
+ # `Rails.application.config.paths["app/server_actions"].existent`
26
+ # work — that path enumerator is what
27
+ # {.server_actions_paths_for} consumes during force-load.
28
+ initializer "ruact.register_server_actions_path", before: :set_autoload_paths do |app|
29
+ app.config.paths.add "app/server_actions", with: "app/server_actions"
30
+ candidate = app.root.join("app/server_actions")
31
+ if candidate.directory?
32
+ app.config.autoload_paths << candidate.to_s
33
+ app.config.eager_load_paths << candidate.to_s
34
+ end
35
+ end
36
+
37
+ rake_tasks { load File.expand_path("../tasks/ruact.rake", __dir__) }
38
+
39
+ # Story 8.1 — clear the action/query registries on every code reload so
40
+ # removed `ruact_action` declarations don't linger in the registry. We hook
41
+ # `before_class_unload` (BEFORE Zeitwerk tears down constants) rather than
42
+ # `to_prepare` (AFTER reload): controller class bodies re-evaluate during
43
+ # the reload itself, and clearing in `to_prepare` would wipe their fresh
44
+ # registrations.
45
+ #
46
+ # First-boot is naturally safe — registries start empty, so there's nothing
47
+ # to clear; the very first controller class-body evaluation populates them.
48
+ # In production this hook never fires (no reloads), which is correct.
49
+ initializer "ruact.attach_registry_clear_hook" do |app|
50
+ app.reloader.before_class_unload do
51
+ Ruact.action_registry.clear!
52
+ Ruact.query_registry.clear!
53
+ end
54
+ end
55
+
56
+ # Story 8.1 — mount the single gem-managed endpoint that dispatches all
57
+ # `ruact_action` calls. The route is `POST /__ruact/fn/:name`; the
58
+ # controller resolves `:name` against `Ruact.action_registry` (and, once
59
+ # Story 9.1 lands, `Ruact.query_registry`) and delegates execution to the
60
+ # entry's host controller class via Rails' normal `dispatch` flow.
61
+ #
62
+ # Story 8.1 Re-run-2 (2026-05-14) — `routes.prepend` (not `.append`) so
63
+ # the gem's reserved `/__ruact/fn/:name` endpoint wins ahead of any host
64
+ # catch-all (e.g. `match "*path", to: "errors#not_found"` or a wildcard
65
+ # POST handler) that would otherwise swallow every server-function call
66
+ # before Ruact saw it. The `/__ruact/fn/` URL prefix is the gem's
67
+ # documented reserved namespace; hosts that route under `/__ruact/` are
68
+ # using the same prefix at their own risk.
69
+ initializer "ruact.mount_server_functions_route" do |app|
70
+ app.routes.prepend do
71
+ post "/__ruact/fn/:name",
72
+ to: "ruact/server_functions/endpoint#dispatch_action",
73
+ as: :ruact_server_function,
74
+ constraints: { name: /[a-zA-Z_][a-zA-Z0-9_]*/ }
75
+ end
13
76
  end
14
77
 
15
78
  # Load the client manifest at boot (and on each code reload in development).
@@ -22,7 +85,7 @@ module Ruact
22
85
  # - production: raises ManifestError; app does not start
23
86
  #
24
87
  # Also registers ActionView integration:
25
- # - ViewHelper provides __rsc_component__ in every view context
88
+ # - ViewHelper provides __ruact_component__ in every view context
26
89
  # - ErbPreprocessorHook applies the RSC preprocessor to all ERB templates
27
90
  # (layouts, views, partials) transparently via prepend.
28
91
  config.to_prepare do
@@ -40,6 +103,26 @@ module Ruact
40
103
  require_relative "erb_preprocessor_hook"
41
104
  ActionView::Base.include(Ruact::ViewHelper)
42
105
  ActionView::Template::Handlers::ERB.prepend(Ruact::ErbPreprocessorHook)
106
+
107
+ # Story 8.1 review-batch 3 (2026-05-14) — force-load all controller
108
+ # files BEFORE writing the snapshot so the registry sees every
109
+ # `ruact_action` declaration. Without this, lazy autoload (Rails dev's
110
+ # default for `eager_load = false`) means a controller that hasn't
111
+ # been requested yet isn't loaded, so its `ruact_action` calls don't
112
+ # populate the registry — the codegen would then emit a stale TS
113
+ # module missing those exports until the controller is hit at least
114
+ # once. The endpoint controller would also 404 those names.
115
+ #
116
+ # Story 8.3 — the force-loader was renamed from
117
+ # `force_load_controllers!` to `force_load_server_function_hosts!`
118
+ # and now walks BOTH `app/controllers/**/*_controller.rb` and
119
+ # `app/server_actions/**/*.rb` so standalone modules also register
120
+ # at boot. The old method name is kept as an alias for back-compat
121
+ # with anything outside the gem calling it.
122
+ Ruact::Railtie.force_load_server_function_hosts!
123
+
124
+ Ruact::Railtie.write_server_functions_snapshot!
125
+ Ruact::ServerFunctions.write_v2_snapshot!(route_set: Rails.application.routes, root: Rails.root)
43
126
  end
44
127
 
45
128
  # Detect streaming capability at boot and log the active mode (AC#1–3).
@@ -83,6 +166,140 @@ module Ruact
83
166
  "— run npm run dev for HMR"
84
167
  end
85
168
 
169
+ # Story 8.1 review-batch 3 (2026-05-14) — force-loads every controller
170
+ # file under `Rails.application.config.paths["app/controllers"]` so the
171
+ # `ruact_action` registrations populate the registry on a clean boot.
172
+ #
173
+ # Without this, Rails' dev-mode lazy autoload only loads a controller
174
+ # when it's first referenced (typically the first request that routes
175
+ # to it). That means the codegen snapshot in `to_prepare` would miss
176
+ # any controller not yet touched.
177
+ #
178
+ # Implementation: glob the `app/controllers` directories listed in the
179
+ # Rails paths configuration and `require_dependency` each
180
+ # `*_controller.rb` file. `require_dependency` works in both Zeitwerk
181
+ # (Rails 7+) and the classic autoloader. On Zeitwerk it is implemented
182
+ # as `Rails.autoloaders.main.load_file(path)` under the hood.
183
+ #
184
+ # Errors are surfaced as `Ruact::Error` with a controller hint so the
185
+ # developer sees a meaningful boot failure instead of a silent skip.
186
+ #
187
+ # Re-run-6 (2026-05-15) — also walks every mounted `Rails::Engine`
188
+ # (and `Rails::Railtie` with `paths["app/controllers"]`) so engine-
189
+ # owned controllers that declare `ruact_action` populate the registry
190
+ # at boot. Without this an engine's `ruact_action` declarations would
191
+ # only register on first request that touches the engine controller —
192
+ # codegen + endpoint dispatch would lag behind boot.
193
+ #
194
+ # @return [Integer] number of controller files loaded.
195
+ def self.force_load_server_function_hosts!
196
+ loaded = 0
197
+
198
+ controller_paths_for(Rails.application).each do |dir|
199
+ loaded += force_load_dir(dir, glob: "**/*_controller.rb")
200
+ end
201
+ server_actions_paths_for(Rails.application).each do |dir|
202
+ loaded += force_load_dir(dir, glob: "**/*.rb")
203
+ end
204
+
205
+ if defined?(Rails::Engine)
206
+ Rails::Engine.subclasses.each do |engine_class|
207
+ # Skip the host app itself; `Rails.application.class` is a
208
+ # `Rails::Engine` subclass and was already covered above.
209
+ next if engine_class == Rails.application.class
210
+
211
+ engine = safe_engine_instance(engine_class)
212
+ next unless engine
213
+
214
+ controller_paths_for(engine).each { |dir| loaded += force_load_dir(dir, glob: "**/*_controller.rb") }
215
+ server_actions_paths_for(engine).each { |dir| loaded += force_load_dir(dir, glob: "**/*.rb") }
216
+ end
217
+ end
218
+
219
+ loaded
220
+ rescue LoadError, NameError => e
221
+ raise Ruact::Error,
222
+ "ruact: failed to force-load a server-function host while populating " \
223
+ "Ruact.action_registry: #{e.class}: #{e.message}. The gem " \
224
+ "force-loads `app/controllers/**/*_controller.rb` and " \
225
+ "`app/server_actions/**/*.rb` at `config.to_prepare` so " \
226
+ "registries are complete on first boot."
227
+ end
228
+
229
+ # Story 8.3 — back-compat alias. Older code paths (rake tasks shipped
230
+ # in previous gem versions, downstream tooling) may call the old
231
+ # name; the new name describes the wider behavior accurately.
232
+ class << self
233
+ alias force_load_controllers! force_load_server_function_hosts!
234
+ end
235
+
236
+ # @param engine [Rails::Engine] either `Rails.application` or a mounted engine
237
+ # @return [Array<String>] existing controller directory paths
238
+ def self.controller_paths_for(engine)
239
+ return [] unless engine.respond_to?(:config) && engine.config.respond_to?(:paths)
240
+
241
+ paths = engine.config.paths["app/controllers"]
242
+ return [] unless paths.respond_to?(:existent)
243
+
244
+ paths.existent
245
+ end
246
+
247
+ # @param dir [String]
248
+ # @param glob [String] glob pattern relative to +dir+; defaults to
249
+ # `**/*_controller.rb` for back-compat with pre-Story-8.3 callers.
250
+ # @return [Integer] number of files loaded
251
+ def self.force_load_dir(dir, glob: "**/*_controller.rb")
252
+ Dir.glob("#{dir}/#{glob}").each do |file|
253
+ require_dependency(file)
254
+ end.length
255
+ end
256
+
257
+ # Story 8.3 — locates `app/server_actions/` for the host application
258
+ # or a mounted engine. Uses the Rails `paths` enumerator (populated by
259
+ # the Railtie initializer) when present, falling back to a direct
260
+ # `engine.root.join` lookup for engines that haven't registered the
261
+ # path. Returns an empty array when the directory doesn't exist —
262
+ # silent no-op for hosts that don't use standalone actions.
263
+ def self.server_actions_paths_for(engine)
264
+ if engine.respond_to?(:config) && engine.config.respond_to?(:paths)
265
+ paths = engine.config.paths["app/server_actions"]
266
+ if paths.respond_to?(:existent)
267
+ existent = paths.existent
268
+ return existent unless existent.empty?
269
+ end
270
+ end
271
+
272
+ return [] unless engine.respond_to?(:root) && engine.root
273
+
274
+ candidate = engine.root.join("app/server_actions")
275
+ candidate.directory? ? [candidate.to_s] : []
276
+ end
277
+
278
+ # Some engine subclasses are abstract (no `.instance` defined yet) or fail
279
+ # at `.instance` if their config block raises. Swallow those quietly —
280
+ # the gem's responsibility is to load whatever it can; engines whose
281
+ # `.instance` blows up will surface on first request anyway.
282
+ def self.safe_engine_instance(engine_class)
283
+ engine_class.instance
284
+ rescue StandardError
285
+ nil
286
+ end
287
+
288
+ # Writes the server-functions JSON snapshot to tmp/cache/ruact/ on every
289
+ # config.to_prepare. The write is short-circuited when the registry payload
290
+ # is unchanged (Story 8.0a — pitfall #1: dev mode fires to_prepare per
291
+ # request; a naive rewrite would burn IOPS and confuse the Vite plugin's
292
+ # chokidar watcher).
293
+ #
294
+ # @return [Boolean] true if a fresh file was written, false if unchanged.
295
+ def self.write_server_functions_snapshot!
296
+ Ruact::ServerFunctions::Snapshot.generate!(
297
+ action_registry: Ruact.action_registry,
298
+ query_registry: Ruact.query_registry,
299
+ path: Rails.root.join("tmp/cache/ruact/server-functions.json")
300
+ )
301
+ end
302
+
86
303
  # Checks whether the manifest exists and either warns (dev) or raises (prod).
87
304
  # Extracted as a class method for direct testability without a full Rails app.
88
305
  def self.check_manifest!(manifest_path)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ # Per-render mutable state holding the components encountered during ERB
5
+ # evaluation. One instance is allocated by the controller per render, passed
6
+ # explicitly through the pipeline, and discarded when the response is sent.
7
+ #
8
+ # No shared state, no thread-local lookup — the instance lives only as long
9
+ # as the render call. Satisfies NFR8 and the `Ruact/NoSharedState` cop with
10
+ # zero exceptions in `lib/`.
11
+ #
12
+ # Internal API: not part of the public compatibility contract.
13
+ class RenderContext
14
+ def initialize
15
+ @components = []
16
+ end
17
+
18
+ attr_reader :components
19
+
20
+ def register(name, props)
21
+ token = "__RUACT_#{@components.length}__"
22
+ @components << { token: token, name: name, props: props }
23
+ token
24
+ end
25
+
26
+ def by_token(token)
27
+ @components.find { |c| c[:token] == token }
28
+ end
29
+ end
30
+ end
@@ -3,47 +3,204 @@
3
3
  require "erb"
4
4
 
5
5
  module Ruact
6
- # Orchestrates the full server component render:
6
+ # Internal — orchestrates the full server component render:
7
7
  # ERB source → (preprocessor) → evaluated HTML → (HtmlConverter) → ReactElement tree
8
8
  # → (Flight::Renderer) → wire bytes
9
9
  #
10
- # Two entry points:
11
- # call/stream full pipeline from ERB source (used in unit tests and legacy path)
12
- # from_html — takes pre-rendered HTML from ActionView (used by Controller#rsc_render)
10
+ # External code should use `Ruact::Controller#ruact_render` instead. `Ruact::RenderPipeline`
11
+ # (and the rest of `Ruact::Flight::*` / `Ruact::Internal::*`) are not part of the public API
12
+ # and may change between minor versions.
13
+ #
14
+ # Single entry point: {#render}.
13
15
  class RenderPipeline
16
+ VALID_MODES = %i[string stream].freeze
17
+
14
18
  def initialize(manifest, controller_path: nil, logger: nil)
15
19
  @manifest = manifest
16
20
  @controller_path = controller_path
17
21
  @logger = logger
18
22
  end
19
23
 
20
- # Render ERB source within a given binding, return Flight wire format string.
21
- # Deferred chunk delays are skipped — suitable for buffered responses (HTML shell).
22
- def call(erb_source, binding_context)
23
- _stream(erb_source, binding_context, streaming: false).to_a.join
24
+ # Render a server component tree to Flight wire format.
25
+ #
26
+ # **Internal API.** External code should call `Ruact::Controller#ruact_render` instead.
27
+ # `Ruact::RenderPipeline` is not part of the public API and may change between minor
28
+ # versions without deprecation. Reach into it only when extending the gem itself.
29
+ #
30
+ # @param input [Hash] selects the input shape — exactly one of:
31
+ # - `{ erb: String, binding: Binding }` — render an ERB template within the given binding.
32
+ # Both keys required; `:erb` must be a `String`, `:binding` must be a `Binding`.
33
+ # - `{ html: String, render_context: Ruact::RenderContext }` — convert pre-rendered HTML
34
+ # (from ActionView) using a render context populated during ERB evaluation.
35
+ # Both keys required; `:html` must be a `String`, `:render_context` must be a
36
+ # `Ruact::RenderContext`.
37
+ # Extra keys beyond the two-key shapes above are rejected with `ArgumentError`.
38
+ # @param mode [Symbol] one of:
39
+ # - `:string` — returns the full serialized `String`. Deferred chunks are inlined
40
+ # eagerly (no Suspense delay). Suitable for buffered HTTP responses.
41
+ # - `:stream` — returns an `Enumerator` that yields Flight rows one at a time.
42
+ # Deferred chunks delay. Suitable for `ActionController::Live` streaming.
43
+ # @return [String, Enumerator] depending on `mode`.
44
+ # @raise [ArgumentError] when:
45
+ # - `input` is not a `Hash`,
46
+ # - `input` mixes `:erb` and `:html` keys,
47
+ # - `input` omits both `:erb` and `:html`,
48
+ # - the required sibling key is missing or has the wrong type
49
+ # (`:binding` must be a `Binding`; `:render_context` must be a `Ruact::RenderContext`),
50
+ # - `:erb` or `:html` is not a `String`,
51
+ # - `input` contains extra keys beyond the documented shapes,
52
+ # - `mode` is not in {VALID_MODES}.
53
+ # All `ArgumentError` messages name the offending input and reference
54
+ # `RenderPipeline#render` for the canonical contract.
55
+ #
56
+ # @example ERB source, buffered output
57
+ # pipeline.render({ erb: "<NavBar />", binding: ctx.instance_eval { binding } }, mode: :string)
58
+ # # => "0:[\"$\",\"div\",null,...]\n"
59
+ #
60
+ # @example Pre-rendered HTML, streaming output
61
+ # ctx = Ruact::RenderContext.new
62
+ # pipeline.render({ html: "<!-- __RUACT_0__ -->", render_context: ctx }, mode: :stream)
63
+ # # => #<Enumerator: ...> (yields Flight rows lazily)
64
+ def render(input, mode: :string)
65
+ validate_mode!(mode)
66
+ enum = build_enum(input, streaming: mode == :stream)
67
+ mode == :string ? enum.to_a.join : enum
24
68
  end
25
69
 
26
- # Render ERB source and return an Enumerator that yields Flight rows one at a time.
27
- # Deferred chunk delays ARE applied — suitable for ActionController::Live streaming.
28
- def stream(erb_source, binding_context)
29
- _stream(erb_source, binding_context, streaming: true)
70
+ private
71
+
72
+ ALLOWED_INPUT_KEYS = %i[erb binding html render_context].freeze
73
+ private_constant :ALLOWED_INPUT_KEYS
74
+
75
+ DOCSTRING_REF = "see RenderPipeline#render docstring for the canonical contract"
76
+ private_constant :DOCSTRING_REF
77
+
78
+ def validate_mode!(mode)
79
+ return if VALID_MODES.include?(mode)
80
+
81
+ raise ArgumentError,
82
+ "ruact: unknown render mode #{mode.inspect}; expected one of #{VALID_MODES.inspect} " \
83
+ "(#{DOCSTRING_REF})"
30
84
  end
31
85
 
32
- # Convert pre-rendered HTML (from ActionView) to Flight wire rows.
33
- #
34
- # IMPORTANT — Eager registry capture: ComponentRegistry.components is read
35
- # immediately when this method is called, before the Enumerator is returned.
36
- # This allows the caller to call ComponentRegistry.reset right after from_html
37
- # returns (inside an ensure block) without affecting the captured registry.
86
+ def build_enum(input, streaming:)
87
+ unless input.is_a?(Hash)
88
+ raise ArgumentError,
89
+ "ruact: render input must be a Hash; got #{input.class} (#{DOCSTRING_REF})"
90
+ end
91
+
92
+ extra = input.keys - ALLOWED_INPUT_KEYS
93
+ unless extra.empty?
94
+ raise ArgumentError,
95
+ "ruact: render input contains unsupported keys: #{extra.inspect}; " \
96
+ "expected only #{ALLOWED_INPUT_KEYS.inspect} (#{DOCSTRING_REF})"
97
+ end
98
+
99
+ has_erb = input.key?(:erb)
100
+ has_html = input.key?(:html)
101
+
102
+ if has_erb && has_html
103
+ raise ArgumentError,
104
+ "ruact: render input cannot mix :erb and :html keys (got both); choose one source shape " \
105
+ "(#{DOCSTRING_REF})"
106
+ end
107
+
108
+ unless has_erb || has_html
109
+ raise ArgumentError,
110
+ "ruact: render input must include either :erb (with sibling :binding) " \
111
+ "or :html (with sibling :render_context) (#{DOCSTRING_REF})"
112
+ end
113
+
114
+ if has_erb
115
+ validate_erb_shape!(input)
116
+ render_erb_enum(input[:erb], input[:binding], streaming: streaming)
117
+ else
118
+ validate_html_shape!(input)
119
+ render_html_enum(input[:html], input[:render_context], streaming: streaming)
120
+ end
121
+ end
122
+
123
+ def validate_erb_shape!(input)
124
+ erb = input[:erb]
125
+ unless erb.is_a?(String)
126
+ raise ArgumentError,
127
+ "ruact: render input :erb must be a String; got #{erb.class} (#{DOCSTRING_REF})"
128
+ end
129
+ unless input.key?(:binding)
130
+ raise ArgumentError,
131
+ "ruact: render input { erb: ... } requires sibling :binding (Binding) (#{DOCSTRING_REF})"
132
+ end
133
+ bnd = input[:binding]
134
+ return if bnd.is_a?(Binding)
135
+
136
+ raise ArgumentError,
137
+ "ruact: render input :binding must be a Binding; got #{bnd.class} (#{DOCSTRING_REF})"
138
+ end
139
+
140
+ def validate_html_shape!(input)
141
+ html = input[:html]
142
+ unless html.is_a?(String)
143
+ raise ArgumentError,
144
+ "ruact: render input :html must be a String; got #{html.class} (#{DOCSTRING_REF})"
145
+ end
146
+ unless input.key?(:render_context)
147
+ raise ArgumentError,
148
+ "ruact: render input { html: ... } requires sibling :render_context " \
149
+ "(Ruact::RenderContext) (#{DOCSTRING_REF})"
150
+ end
151
+ ctx = input[:render_context]
152
+ return if ctx.is_a?(RenderContext)
153
+
154
+ raise ArgumentError,
155
+ "ruact: render input :render_context must be a Ruact::RenderContext; " \
156
+ "got #{ctx.class} (#{DOCSTRING_REF})"
157
+ end
158
+
159
+ # ERB-source path: evaluate ERB, collect components into a fresh RenderContext via
160
+ # injected `__ruact_component__` helper, then convert + serialize.
38
161
  #
39
- # The returned Enumerator does NOT reference ComponentRegistry at all —
40
- # only the eagerly-captured +registry+ local variable.
41
- def from_html(html, streaming: false)
42
- registry = ComponentRegistry.components.map do |entry|
162
+ # NB: `Ruact.config.strict_serialization` and the `as_json` warning callback are read
163
+ # inside the Enumerator block (consumption time), preserving the legacy `_stream`
164
+ # behavior config changes between `#render` returning and the Enumerator being
165
+ # consumed are observable.
166
+ def render_erb_enum(erb_source, binding_context, streaming:)
167
+ Enumerator.new do |y|
168
+ render_context = RenderContext.new
169
+ transformed = ErbPreprocessor.transform(erb_source)
170
+ receiver = binding_context.eval("self")
171
+ prev_ctx = receiver.instance_variable_get(:@__ruact_render_context__)
172
+ inject_helper!(binding_context, render_context)
173
+ html =
174
+ begin
175
+ ERB.new(transformed).result(binding_context)
176
+ ensure
177
+ receiver.instance_variable_set(:@__ruact_render_context__, prev_ctx)
178
+ end
179
+
180
+ registry = render_context.components.map do |entry|
181
+ ref = @manifest.reference_for(entry[:name], controller_path: @controller_path)
182
+ { token: entry[:token], name: entry[:name], ref: ref, props: entry[:props] }
183
+ end
184
+
185
+ root_element = HtmlConverter.convert(html, registry)
186
+
187
+ Flight::Renderer.each(root_element, @manifest,
188
+ strict_serialization: Ruact.config.strict_serialization,
189
+ on_as_json_warning: as_json_warning_callback,
190
+ streaming: streaming) { |row| y << row }
191
+ end
192
+ end
193
+
194
+ # HTML path: input HTML is already rendered by ActionView; the supplied RenderContext
195
+ # holds the components encountered during ERB evaluation. Registry is captured eagerly so
196
+ # the caller may discard the RenderContext immediately after `render` returns — the
197
+ # returned Enumerator does not reference the RenderContext.
198
+ def render_html_enum(html, render_context, streaming:)
199
+ registry = render_context.components.map do |entry|
43
200
  ref = @manifest.reference_for(entry[:name], controller_path: @controller_path)
44
201
  { token: entry[:token], name: entry[:name], ref: ref, props: entry[:props] }
45
202
  end
46
- strict = Ruact.config.strict_serialization
203
+ strict = Ruact.config.strict_serialization
47
204
  warning_cb = as_json_warning_callback
48
205
 
49
206
  Enumerator.new do |y|
@@ -55,33 +212,6 @@ module Ruact
55
212
  end
56
213
  end
57
214
 
58
- private
59
-
60
- def _stream(erb_source, binding_context, streaming: false)
61
- Enumerator.new do |y|
62
- ComponentRegistry.start
63
- begin
64
- transformed = ErbPreprocessor.transform(erb_source)
65
- inject_helper!(binding_context)
66
- html = ERB.new(transformed).result(binding_context)
67
-
68
- registry = ComponentRegistry.components.map do |entry|
69
- ref = @manifest.reference_for(entry[:name], controller_path: @controller_path)
70
- { token: entry[:token], name: entry[:name], ref: ref, props: entry[:props] }
71
- end
72
-
73
- root_element = HtmlConverter.convert(html, registry)
74
-
75
- Flight::Renderer.each(root_element, @manifest,
76
- strict_serialization: Ruact.config.strict_serialization,
77
- on_as_json_warning: as_json_warning_callback,
78
- streaming: streaming) { |row| y << row }
79
- ensure
80
- ComponentRegistry.reset
81
- end
82
- end
83
- end
84
-
85
215
  def as_json_warning_callback
86
216
  return nil if @logger.nil?
87
217
 
@@ -89,19 +219,31 @@ module Ruact
89
219
  @logger.warn(
90
220
  "[ruact] WARNING: #{class_name} serialized via as_json — " \
91
221
  "ALL attributes exposed to client: #{attrs}. " \
92
- "Use `include Ruact::Serializable` with `rsc_props` for explicit control"
222
+ "Use `include Ruact::Serializable` with `ruact_props` for explicit control"
93
223
  )
94
224
  end
95
225
  end
96
226
 
97
- # Define __rsc_component__ in the ERB binding so it can be called.
98
- def inject_helper!(binding_context)
99
- binding_context.eval(<<~RUBY)
100
- def __rsc_component__(name, props = {})
101
- token = ::Ruact::ComponentRegistry.register(name, props)
102
- "<!-- \#{token} -->"
103
- end
104
- RUBY
227
+ # Install __ruact_component__ on the binding's receiver and stash the active
228
+ # render context on the receiver as @__ruact_render_context__. The singleton
229
+ # method reads the ivar dynamically, so nested renders sharing the same
230
+ # receiver (e.g., inner pipeline.render from inside ERB) see whatever context
231
+ # is current at invocation time — render_erb_enum wraps ERB evaluation in a
232
+ # save/restore block so the outer render's context is restored afterward.
233
+ # Defining the singleton method is idempotent — re-defining on a nested
234
+ # call would not change behaviour, but skipping it avoids needless work.
235
+ def inject_helper!(binding_context, render_context)
236
+ receiver = binding_context.eval("self")
237
+ receiver.instance_variable_set(:@__ruact_render_context__, render_context)
238
+ return if receiver.respond_to?(:__ruact_component__)
239
+
240
+ receiver.define_singleton_method(:__ruact_component__) do |name, props = {}|
241
+ ctx = instance_variable_get(:@__ruact_render_context__)
242
+ raise Ruact::Error, "ruact: __ruact_component__ called outside an active render context" if ctx.nil?
243
+
244
+ token = ctx.register(name, props)
245
+ "<!-- #{token} -->"
246
+ end
105
247
  end
106
248
  end
107
249
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_dispatch"
4
+
5
+ module Ruact
6
+ # Story 9.4 (D8) — the `ruact_queries` routing macro. Included into
7
+ # `ActionDispatch::Routing::Mapper` by the Railtie, so it is available
8
+ # inside `Rails.application.routes.draw`:
9
+ #
10
+ # Rails.application.routes.draw do
11
+ # ruact_queries CatalogQuery # GET /q/categories, GET /q/searchUsers, …
12
+ # resources :posts
13
+ # end
14
+ #
15
+ # For each `public_instance_methods(false)` of the query class — methods
16
+ # inherited from `Ruact::Query` / `ApplicationQuery` or mixed in from user
17
+ # modules are NOT mounted (AC1) — one NAMED GET route is drawn at
18
+ # `GET <Ruact.config.query_route_prefix>/<jsIdentifier>` (default `/q`,
19
+ # contract decision #7), pointing at the query class's generated internal
20
+ # dispatch controller ({Ruact::ServerFunctions::QueryDispatch}). Every query
21
+ # is visible in `rails routes` — no hidden endpoint; the route table stays
22
+ # the single source of truth.
23
+ #
24
+ # The path segment reuses {Ruact::ServerFunctions::NameBridge} verbatim
25
+ # (D4): `def search_users` → `GET /q/searchUsers`, named
26
+ # `ruact_query_searchUsers`. Invalid or JS-reserved method names raise
27
+ # {Ruact::ConfigurationError} at route-draw time; two query classes mounting
28
+ # the same method name collide on the route NAME / path and fail Rails' own
29
+ # duplicate checks — both are loud boot failures, never request-time
30
+ # surprises.
31
+ #
32
+ # The generated dispatch controller PRESERVES the query class's namespace
33
+ # (review round 4) — `Admin::CatalogQuery` →
34
+ # `Ruact::ServerFunctions::QueryDispatch::Admin::CatalogQueryController` — so
35
+ # the controller constant is an injective function of the query class's
36
+ # fully-qualified name: two distinct query classes can never map to the same
37
+ # constant, and there is no flatten collision to detect (across any number
38
+ # of RouteSets / mounted engines sharing the global dispatch namespace).
39
+ module Routing
40
+ # Draws the named GET routes for one or more {Ruact::Query} subclasses.
41
+ #
42
+ # @param query_classes [Array<Class>] `Ruact::Query` subclasses to mount.
43
+ # @return [void]
44
+ def ruact_queries(*query_classes)
45
+ query_classes.each { |query_class| Ruact::Routing.draw_query_routes(self, query_class) }
46
+ nil
47
+ end
48
+
49
+ class << self
50
+ # @api private — the macro body, kept off the Mapper instance so the only
51
+ # method `ruact_queries` adds to the routing DSL surface is itself.
52
+ def draw_query_routes(mapper, query_class)
53
+ ServerFunctions::QueryDispatch.controller_for(query_class)
54
+ target = ServerFunctions::QueryDispatch.route_target_for(query_class)
55
+ prefix = Ruact.config.query_route_prefix
56
+
57
+ query_class.public_instance_methods(false).each do |query_method|
58
+ js_identifier = ServerFunctions::NameBridge.to_js_identifier(query_method)
59
+ mapper.get("#{prefix}/#{js_identifier}",
60
+ to: "#{target}##{query_method}",
61
+ as: :"ruact_query_#{js_identifier}")
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ # D8 — installed at require time (the Railtie requires this file from its
69
+ # `ruact.load_controller` initializer; a direct `require "ruact/routing"`
70
+ # in a non-Railtie context gets the same one-shot, idempotent install).
71
+ # Written as a class reopening (rather than
72
+ # `ActionDispatch::Routing::Mapper.include Ruact::Routing`) so YARD's static
73
+ # MixinHandler resolves the named namespace instead of choking on the external
74
+ # constant receiver under `--fail-on-warning`.
75
+ module ActionDispatch # rubocop:disable Style/OneClassPerFile -- deliberate Mapper extension install (D8)
76
+ module Routing
77
+ class Mapper
78
+ include Ruact::Routing
79
+ end
80
+ end
81
+ end