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
data/lib/ruact/railtie.rb CHANGED
@@ -8,7 +8,6 @@ module Ruact
8
8
  require_relative "controller"
9
9
  # Story 9.1 — the v2 route-driven marker concern (`include Ruact::Server`).
10
10
  require_relative "server"
11
- require_relative "server_action"
12
11
  # Story 9.4 (D8) — requiring ruact/routing installs the `ruact_queries`
13
12
  # macro into ActionDispatch::Routing::Mapper (a Mapper extension, NOT a
14
13
  # mounted route — the host's routes.rb explicitly mounts each query
@@ -17,64 +16,8 @@ module Ruact
17
16
  require_relative "routing"
18
17
  end
19
18
 
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
19
  rake_tasks { load File.expand_path("../tasks/ruact.rake", __dir__) }
38
20
 
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
76
- end
77
-
78
21
  # Load the client manifest at boot (and on each code reload in development).
79
22
  # config.to_prepare runs once in production and before every code reload in
80
23
  # development, ensuring the manifest is always current without file I/O per
@@ -104,30 +47,27 @@ module Ruact
104
47
  ActionView::Base.include(Ruact::ViewHelper)
105
48
  ActionView::Template::Handlers::ERB.prepend(Ruact::ErbPreprocessorHook)
106
49
 
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
-
50
+ # Story 9.9 — the route-driven (v2) codegen is the SOLE writer of the
51
+ # real Rails↔Vite bridge (`tmp/cache/ruact/server-functions.json`, which
52
+ # the Vite plugin renders into `app/javascript/.ruact/server-functions.ts`).
53
+ # The v1 registry path was demolished in this story (epic decision #6:
54
+ # the parallel `.next` target ceases to exist; route-driven codegen owns
55
+ # the real file unconditionally there are no v1 declarations possible
56
+ # anymore).
124
57
  Ruact::Railtie.write_server_functions_snapshot!
125
- Ruact::ServerFunctions.write_v2_snapshot!(route_set: Rails.application.routes, root: Rails.root)
126
58
  end
127
59
 
128
60
  # Detect streaming capability at boot and log the active mode (AC#1–3).
129
61
  # Also warns in development if the Vite dev server is not running (AC#4, #7).
130
62
  config.after_initialize do
63
+ # Story 9.9 — guaranteed-correct first-boot codegen. By `after_initialize`
64
+ # the route table is fully drawn on EVERY Rails version, so this write
65
+ # always sees the complete route set (the `to_prepare` write above can run
66
+ # mid-finalization on Rails < 8, before routes exist — that write is the
67
+ # dev-reload refresh path; this one is the authoritative boot write).
68
+ # Write-if-changed makes the pair idempotent — at most one real write.
69
+ Ruact::Railtie.write_server_functions_snapshot!
70
+
131
71
  Ruact::Railtie.detect_streaming_mode!
132
72
  next unless Rails.env.development?
133
73
 
@@ -160,144 +100,71 @@ module Ruact
160
100
  # Extracted as a class method for direct testability without a full Rails app.
161
101
  def self.check_vite!
162
102
  require "socket"
163
- TCPSocket.new("localhost", 5173).close
164
- rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
165
- Rails.logger.warn "[ruact] Vite dev server not detected at localhost:5173 " \
103
+ require "uri"
104
+ uri = URI.parse(Ruact.config.vite_dev_server)
105
+ host = uri.host || "localhost"
106
+ port = uri.port || 5173
107
+ # `connect_timeout` so a blackholed/remote configured host can't stall dev
108
+ # boot from `after_initialize` until the OS TCP timeout (matches
109
+ # ViewHelper#vite_dev_running?).
110
+ TCPSocket.new(host, port, connect_timeout: 1).close
111
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError
112
+ # Broad rescue (incl. SocketError) because the host is now configurable: a
113
+ # misconfigured/unresolvable `vite_dev_server` must downgrade to this dev
114
+ # warning, never crash boot from `after_initialize`. `host`/`port` are
115
+ # assigned before the connect attempt, so they are always in scope here.
116
+ Rails.logger.warn "[ruact] Vite dev server not detected at #{host}:#{port} " \
166
117
  "— run npm run dev for HMR"
167
118
  end
168
119
 
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.
120
+ # Story 9.9 — writes the route-driven (v2) server-functions JSON snapshot
121
+ # to the REAL bridge (`tmp/cache/ruact/server-functions.json`) on every
122
+ # `config.to_prepare`. The Vite plugin renders this JSON into
123
+ # `app/javascript/.ruact/server-functions.ts`. The write is short-circuited
124
+ # when the entries are unchanged (Story 8.0a pitfall #1 — dev mode fires
125
+ # `to_prepare` per request; a naive rewrite would burn IOPS and churn the
126
+ # Vite plugin's chokidar watcher).
183
127
  #
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.
128
+ # Cold-boot ordering (folded in from the Story 9.8 host workaround): on the
129
+ # very first boot `to_prepare` can run BEFORE `routes.rb` is drawn, leaving
130
+ # the route set empty so the codegen would emit zero functions. Force the
131
+ # route table to load first via `routes_reloader.execute_unless_loaded` so
132
+ # RouteSource/QuerySource see every route.
186
133
  #
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.
134
+ # The force-load is guarded: when `to_prepare` fires from INSIDE the
135
+ # boot finisher (Rails draws routes during `initialize!`, and on Rails < 8
136
+ # re-entering `execute_unless_loaded` there raises `FrozenError` against the
137
+ # in-progress route set), we SKIP THE WRITE ENTIRELY rather than publish a
138
+ # snapshot from a mid-draw/incomplete route set (which Vite could observe as
139
+ # a transient empty/stale bridge Codex R1). The authoritative boot write
140
+ # is the `after_initialize` call, which always sees the fully-drawn table;
141
+ # the dev-reload `to_prepare` then refreshes from a settled table.
193
142
  #
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
143
+ # @return [Array<Hash>, nil] the exposed v2 entries, or nil if the write was
144
+ # skipped because routes were mid-draw.
145
+ def self.write_server_functions_snapshot!
146
+ return nil unless force_routes_loaded!
277
147
 
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
148
+ Ruact::ServerFunctions.write_v2_snapshot!(
149
+ route_set: Rails.application.routes, root: Rails.root
150
+ )
286
151
  end
287
152
 
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).
153
+ # Idempotently force the route table to load. Returns true when the table is
154
+ # safely loaded (so the caller may read it), false when the force re-entered
155
+ # the boot finisher's in-progress draw (Rails < 8 raises `FrozenError`
156
+ # there) in which case the caller must NOT write, leaving the table to the
157
+ # routes initializer + the later `after_initialize` write.
293
158
  #
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
- )
159
+ # @return [Boolean] true if routes are safe to read, false if mid-draw.
160
+ def self.force_routes_loaded!
161
+ reloader = Rails.application.routes_reloader
162
+ return true unless reloader.respond_to?(:execute_unless_loaded)
163
+
164
+ reloader.execute_unless_loaded
165
+ true
166
+ rescue FrozenError
167
+ false
301
168
  end
302
169
 
303
170
  # Checks whether the manifest exists and either warns (dev) or raises (prod).
@@ -166,7 +166,14 @@ module Ruact
166
166
  def render_erb_enum(erb_source, binding_context, streaming:)
167
167
  Enumerator.new do |y|
168
168
  render_context = RenderContext.new
169
- transformed = ErbPreprocessor.transform(erb_source)
169
+ # `registry: nil` → fail-open, no FR100 contract validation for this
170
+ # low-level programmatic render path (contract checking lives on the
171
+ # ActionView preprocessor hook used by real controllers). This keeps the
172
+ # path off `Ruact::ManifestResolver` entirely — historically it read the
173
+ # global `Ruact.manifest` (typically nil here, so already no validation),
174
+ # so passing nil is byte-for-byte equivalent while avoiding any dev HTTP
175
+ # fetch / test-double dispatch inside callers that measure this path.
176
+ transformed = ErbPreprocessor.transform(erb_source, registry: nil)
170
177
  receiver = binding_context.eval("self")
171
178
  prev_ctx = receiver.instance_variable_get(:@__ruact_render_context__)
172
179
  inject_helper!(binding_context, render_context)
data/lib/ruact/server.rb CHANGED
@@ -14,6 +14,7 @@ require_relative "../ruact"
14
14
  require_relative "server_functions/error_rendering"
15
15
  require_relative "server_functions/bucket_two_payload"
16
16
  require_relative "server_functions/name_bridge"
17
+ require_relative "validation_errors_collector"
17
18
 
18
19
  module Ruact
19
20
  # Story 9.1 (route-driven redesign, Phase A) — the v2 server-functions
@@ -59,16 +60,16 @@ module Ruact
59
60
  # Contract simplification: the concern assumes the host includes it after
60
61
  # `protect_from_forgery`; no runtime callback-order verifier runs here.
61
62
  #
62
- # Both bodies live in {Ruact::ServerFunctions::ErrorRendering}, shared with
63
- # the v1 {Ruact::ServerFunctions::EndpointController} during the
64
- # strangler-fig transition so the wire contract is identical by
65
- # construction. Dual-bucket response negotiation (ivar serialization,
63
+ # Both bodies live in {Ruact::ServerFunctions::ErrorRendering} (Story 9.9 —
64
+ # this concern is now the sole home; the v1 endpoint that previously shared it
65
+ # was demolished). Dual-bucket response negotiation (ivar serialization,
66
66
  # `$redirect`, 204, `Vary: Accept`) is Story 9.2; this concern only
67
67
  # contributes the discrimination predicate 9.2 will reuse.
68
68
  module Server
69
69
  extend ActiveSupport::Concern
70
70
 
71
71
  include Ruact::ServerFunctions::ErrorRendering
72
+ include Ruact::ValidationErrorsCollector
72
73
 
73
74
  included do
74
75
  # Story 8.5 salvage — prepended so the size check wins the race against
@@ -159,7 +160,7 @@ module Ruact
159
160
  raise Ruact::ConfigurationError,
160
161
  "ruact_function_name :#{action}, as: #{as.inspect} — " \
161
162
  "\"#{js}\" is a reserved JS word or is already bound by the ruact runtime " \
162
- "(`_makeRef` / `_makeServerFunction` / `revalidate`); pick another name"
163
+ "(`_makeServerFunction` / `_makeQuery` / `revalidate` / `useQuery`); pick another name"
163
164
  end
164
165
 
165
166
  __ruact_function_name_overrides[action.to_s] = js
@@ -194,7 +195,28 @@ module Ruact
194
195
  def default_render(*)
195
196
  return super unless __ruact_function_call?
196
197
 
197
- assigns = view_assigns
198
+ # Story 13.3 (FR98) — the collector's framework-internal ivars (and a stray
199
+ # dev `@errors`) are dropped from the serialized assigns; Rails' own
200
+ # `view_assigns` only filters a fixed set of `@_` ivars, not every `@__`
201
+ # one (see {Ruact::ValidationErrorsCollector::RESERVED_ASSIGN_KEYS}).
202
+ assigns = view_assigns.except(*ValidationErrorsCollector::RESERVED_ASSIGN_KEYS)
203
+
204
+ # Story 13.3 (FR98, AC3) — when the host opted into the validation-error
205
+ # round-trip (`ruact_errors(@record)` was called this request), inject the
206
+ # collected errors under the reserved JSON key `"errors"` alongside the
207
+ # serialized ivars, even on an otherwise-empty assigns set: an opted-in
208
+ # success must still surface `{"errors": {}}` so the client's `await`
209
+ # result carries `result.errors` on a single code path.
210
+ if __ruact_errors_touched?
211
+ payload = ServerFunctions::BucketTwoPayload.build(
212
+ assigns, strict: Ruact.config.strict_serialization
213
+ )
214
+ payload["errors"] = __ruact_errors
215
+ return render(json: payload)
216
+ end
217
+
218
+ # An UNTOUCHED collector changes nothing — the 9.2 `204 No Content`
219
+ # empty-Bucket-2 contract is preserved.
198
220
  return head(:no_content) if assigns.empty?
199
221
 
200
222
  render json: ServerFunctions::BucketTwoPayload.build(