ruact 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) 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 +68 -17
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
  93. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
  94. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  95. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  96. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  97. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  98. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  99. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  102. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  104. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  107. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  119. metadata +55 -15
  120. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  121. data/lib/ruact/server_action.rb +0 -131
  122. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  123. data/lib/ruact/server_functions/registry.rb +0 -148
  124. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  125. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  126. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  127. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  128. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  129. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  130. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  131. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  132. 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
 
@@ -166,138 +106,54 @@ module Ruact
166
106
  "— run npm run dev for HMR"
167
107
  end
168
108
 
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.
109
+ # Story 9.9 — writes the route-driven (v2) server-functions JSON snapshot
110
+ # to the REAL bridge (`tmp/cache/ruact/server-functions.json`) on every
111
+ # `config.to_prepare`. The Vite plugin renders this JSON into
112
+ # `app/javascript/.ruact/server-functions.ts`. The write is short-circuited
113
+ # when the entries are unchanged (Story 8.0a pitfall #1 — dev mode fires
114
+ # `to_prepare` per request; a naive rewrite would burn IOPS and churn the
115
+ # Vite plugin's chokidar watcher).
183
116
  #
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.
117
+ # Cold-boot ordering (folded in from the Story 9.8 host workaround): on the
118
+ # very first boot `to_prepare` can run BEFORE `routes.rb` is drawn, leaving
119
+ # the route set empty so the codegen would emit zero functions. Force the
120
+ # route table to load first via `routes_reloader.execute_unless_loaded` so
121
+ # RouteSource/QuerySource see every route.
186
122
  #
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.
123
+ # The force-load is guarded: when `to_prepare` fires from INSIDE the
124
+ # boot finisher (Rails draws routes during `initialize!`, and on Rails < 8
125
+ # re-entering `execute_unless_loaded` there raises `FrozenError` against the
126
+ # in-progress route set), we SKIP THE WRITE ENTIRELY rather than publish a
127
+ # snapshot from a mid-draw/incomplete route set (which Vite could observe as
128
+ # a transient empty/stale bridge Codex R1). The authoritative boot write
129
+ # is the `after_initialize` call, which always sees the fully-drawn table;
130
+ # the dev-reload `to_prepare` then refreshes from a settled table.
193
131
  #
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
132
+ # @return [Array<Hash>, nil] the exposed v2 entries, or nil if the write was
133
+ # skipped because routes were mid-draw.
134
+ def self.write_server_functions_snapshot!
135
+ return nil unless force_routes_loaded!
277
136
 
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
137
+ Ruact::ServerFunctions.write_v2_snapshot!(
138
+ route_set: Rails.application.routes, root: Rails.root
139
+ )
286
140
  end
287
141
 
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).
142
+ # Idempotently force the route table to load. Returns true when the table is
143
+ # safely loaded (so the caller may read it), false when the force re-entered
144
+ # the boot finisher's in-progress draw (Rails < 8 raises `FrozenError`
145
+ # there) in which case the caller must NOT write, leaving the table to the
146
+ # routes initializer + the later `after_initialize` write.
293
147
  #
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
- )
148
+ # @return [Boolean] true if routes are safe to read, false if mid-draw.
149
+ def self.force_routes_loaded!
150
+ reloader = Rails.application.routes_reloader
151
+ return true unless reloader.respond_to?(:execute_unless_loaded)
152
+
153
+ reloader.execute_unless_loaded
154
+ true
155
+ rescue FrozenError
156
+ false
301
157
  end
302
158
 
303
159
  # Checks whether the manifest exists and either warns (dev) or raises (prod).
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(