ruact 0.0.12 → 0.0.14

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/README.md +4 -4
  4. data/lib/generators/ruact/install/install_generator.rb +418 -128
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +14 -13
  6. data/lib/generators/ruact/install/templates/Procfile.dev.tt +1 -1
  7. data/lib/generators/ruact/install/templates/initializer.rb.tt +29 -7
  8. data/lib/generators/ruact/install/templates/package.json.tt +4 -4
  9. data/lib/generators/ruact/install/templates/tsconfig.json.tt +3 -0
  10. data/lib/generators/ruact/layout/layout_generator.rb +52 -0
  11. data/lib/generators/ruact/scaffold/scaffold_generator.rb +39 -12
  12. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +4 -3
  13. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +8 -8
  14. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +8 -8
  15. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +5 -3
  16. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +2 -2
  17. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +1 -1
  18. data/lib/ruact/configuration.rb +71 -17
  19. data/lib/ruact/controller/document_rendering.rb +72 -16
  20. data/lib/ruact/controller/page_rendering.rb +134 -0
  21. data/lib/ruact/controller/pages.rb +116 -0
  22. data/lib/ruact/controller.rb +78 -11
  23. data/lib/ruact/doctor.rb +233 -25
  24. data/lib/ruact/layout_source.rb +29 -7
  25. data/lib/ruact/navigation_boundary.rb +240 -0
  26. data/lib/ruact/railtie.rb +30 -0
  27. data/lib/ruact/routing.rb +24 -6
  28. data/lib/ruact/server.rb +10 -1
  29. data/lib/ruact/version.rb +1 -1
  30. data/lib/ruact/view_helper.rb +158 -1
  31. data/lib/ruact/views/layouts/ruact.html.erb +32 -0
  32. data/lib/ruact.rb +29 -0
  33. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +433 -0
  34. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +170 -7
  35. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +1 -1
  36. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +3 -3
  37. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +3 -3
  38. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +4 -4
  39. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +4 -4
  40. metadata +8 -2
data/lib/ruact/doctor.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require "socket"
4
5
  require "pathname"
5
6
 
@@ -7,7 +8,8 @@ module Ruact
7
8
  # Runs a suite of installation health checks and prints ✓/✗ per check.
8
9
  # Extracted from the ruact:doctor Rake task for direct testability (FR27).
9
10
  class Doctor # rubocop:disable Metrics/ClassLength
10
- CHECKS = %i[manifest vite controller layout streaming legacy_constant serialize_only flight_middleware].freeze
11
+ CHECKS = %i[manifest vite controller layout head_assets streaming legacy_constant serialize_only
12
+ flight_middleware].freeze
11
13
  # Built via Array#join so the gem-CI `name-propagation` guard does not
12
14
  # match these literals against itself (Story 5.1 review F4 — the doctor
13
15
  # file participates in the guard with no exclusion).
@@ -92,7 +94,7 @@ module Ruact
92
94
  # success. An unexpected status (rendered `✗`) fails loudly rather than
93
95
  # being silently treated as a pass (review finding R1).
94
96
  passed = passed?(computed)
95
- puts "Run rails generate ruact:install to fix configuration issues" unless passed
97
+ puts "Run rails ruact:doctor -- --json for how to fix each failure" unless passed
96
98
  passed
97
99
  end
98
100
 
@@ -161,14 +163,99 @@ module Ruact
161
163
  "Run npm run dev (or bin/dev) to start the Vite dev server."]
162
164
  end
163
165
 
166
+ # Story 17.0g (FR116) — reports the ADOPTION MODE instead of demanding one.
167
+ #
168
+ # Whole-app (`ruact:install --app`): ApplicationController includes the
169
+ # concern, and every action with a template renders through ruact — the
170
+ # message says how many templates that is, which is the cost `--app` hides.
171
+ # Island (the install default): the concern is on the controllers that
172
+ # render ruact pages. It used to FAIL every island app, because it only
173
+ # looked at ApplicationController. With none yet, a warning: the install
174
+ # worked; there is simply no page.
175
+ #
176
+ # Mechanical, like the layout checks: it reads files, it never renders.
164
177
  def check_controller
165
- path = Rails.root.join("app", "controllers", "application_controller.rb")
166
- if File.exist?(path) && File.read(path).include?("Ruact::Controller")
167
- [:pass, "Ruact::Controller included in ApplicationController"]
168
- else
169
- [:fail, "Ruact::Controller not included in ApplicationController",
170
- "Run rails generate ruact:install to include Ruact::Controller in ApplicationController."]
178
+ application = Rails.root.join("app", "controllers", "application_controller.rb")
179
+ if File.exist?(application) && File.read(application).match?(Ruact::CONTROLLER_INCLUDE)
180
+ return [:pass, "whole-app mode: ApplicationController includes Ruact::Controller — " \
181
+ "#{pluralize_count(ruact_page_templates, 'template')} in app/views render through ruact"]
171
182
  end
183
+
184
+ count = island_controllers.length
185
+ return island_without_pages_result if count.zero?
186
+
187
+ verb = count == 1 ? "renders" : "render"
188
+ [:pass, "island mode: #{pluralize_count(count, 'controller')} #{verb} ruact pages (include Ruact::Controller)"]
189
+ end
190
+
191
+ # Controller files that include the concern themselves. Approximate by
192
+ # design — it reads source, it does not load classes: a controller that
193
+ # gets the concern through a base controller counts through the base.
194
+ # `concerns/` holds modules, not controllers; only the app's ROOT
195
+ # ApplicationController is whole-app mode (an `Admin::ApplicationController`
196
+ # is an island base like any other).
197
+ def island_controllers
198
+ root = Rails.root.join("app", "controllers")
199
+ Dir.glob(root.join("**", "*.rb").to_s).select do |file|
200
+ next false if file == root.join("application_controller.rb").to_s
201
+ next false if file.start_with?("#{root.join('concerns')}/")
202
+
203
+ File.read(file).match?(Ruact::CONTROLLER_INCLUDE)
204
+ end
205
+ end
206
+
207
+ # Page templates: `.html.erb` under app/views — not layouts, partials or
208
+ # mailer views. Approximate: `ruact_pages` narrowing is not read.
209
+ def ruact_page_templates
210
+ Dir.glob(Rails.root.join("app", "views", "**", "*.html.erb").to_s).count do |file|
211
+ !file.include?("/app/views/layouts/") && !File.basename(file).start_with?("_") &&
212
+ !File.dirname(file).end_with?("_mailer")
213
+ end
214
+ end
215
+
216
+ def pluralize_count(count, noun)
217
+ "#{count} #{noun}#{'s' unless count == 1}"
218
+ end
219
+
220
+ def island_without_pages_result
221
+ [:warn,
222
+ "island mode: no controller renders ruact pages yet — add include Ruact::Controller to one, " \
223
+ "or run rails generate ruact:scaffold",
224
+ "The install changes no controller (island mode). A page renders through ruact when its " \
225
+ "controller has `include Ruact::Controller`; narrow it with `ruact_pages only: %i[show]`. " \
226
+ "`rails generate ruact:install --app` puts it on ApplicationController instead (every page)."]
227
+ end
228
+
229
+ # Which document a ruact page actually renders into, decided from
230
+ # `Ruact.config.layout` and the files on disk — never by rendering.
231
+ #
232
+ # - `false` → `[:shell, application.html.erb]`: ruact's built-in shell. The
233
+ # application layout is still read, to report a layout that is wired but
234
+ # switched off.
235
+ # - a String → the app's `app/views/layouts/<name>.html.erb` when it exists
236
+ # (an ejected `ruact` layout included), otherwise the gem's own
237
+ # (`layouts/ruact`, Story 17.0b), otherwise `:missing`. The same order the
238
+ # view paths give Rails: the app's views first, the gem's appended last.
239
+ # The `layouts/` prefix Rails accepts is stripped rather than doubled.
240
+ # - `true` → `application.html.erb`. It cannot resolve a controller's own
241
+ # `layout "admin"`, and every message names the file it read so that
242
+ # limit stays visible.
243
+ #
244
+ # @return [Array(Symbol, Pathname)] `[:shell | :app | :gem | :missing, path]`
245
+ def rendering_layout
246
+ layout = Ruact.config.layout
247
+ application = Rails.root.join("app", "views", "layouts", "application.html.erb")
248
+ return [:shell, application] if layout == false
249
+ return [File.exist?(application) ? :app : :missing, application] unless layout.is_a?(String)
250
+
251
+ name = layout.delete_prefix("layouts/")
252
+ app_file = Rails.root.join("app", "views", "layouts", "#{name}.html.erb")
253
+ return [:app, app_file] if File.exist?(app_file)
254
+
255
+ gem_file = Pathname(Ruact.views_path).join("layouts", "#{name}.html.erb")
256
+ return [:gem, gem_file] if gem_file.exist?
257
+
258
+ [:missing, app_file]
172
259
  end
173
260
 
174
261
  # Two independent halves have to line up, and BOTH are silent when wrong:
@@ -178,39 +265,160 @@ module Ruact
178
265
  # errors. Reporting each half separately is the point: "add one line" and
179
266
  # "flip one setting" are different fixes.
180
267
  #
268
+ # Story 17.0b — it reads the layout that RENDERS (see #rendering_layout). It
269
+ # used to read application.html.erb always, which failed a correct fresh
270
+ # install: that one renders through the gem's layout and leaves the app's
271
+ # own untouched, with no React root in it.
272
+ #
181
273
  # `Ruact::LayoutSource` is the SHARED definition of "calls the helper" —
182
274
  # the runtime and `ruact:install` read it too, so this check cannot drift
183
275
  # into disagreeing with what actually happens at render time (it did:
184
276
  # a `<%# TODO: add ruact_js_assets %>` comment used to pass here).
185
277
  def check_layout
186
- path = Rails.root.join("app", "views", "layouts", "application.html.erb")
187
- unless File.exist?(path)
188
- return [:fail, "React shell missing from application.html.erb",
189
- "Run rails generate ruact:install to add the React shell to application.html.erb."]
278
+ kind, path = rendering_layout
279
+ return [:pass, "ruact pages render through ruact's layout (config.layout = \"#{Ruact::GEM_LAYOUT}\")"] if
280
+ kind == :gem
281
+ return layout_missing_result(path) unless File.exist?(path)
282
+
283
+ missing = missing_layout_pieces(File.read(path))
284
+ return layout_unwired_result(path, missing) unless missing.empty?
285
+ return layout_not_opted_in_result if kind == :shell
286
+
287
+ [:pass, "#{path.basename} owns the document (React root + ruact_js_assets, config.layout on)"]
288
+ end
289
+
290
+ def layout_missing_result(path)
291
+ if Ruact.config.layout.is_a?(String)
292
+ [:fail, "config.layout names #{path.basename}, which exists neither in your app nor in ruact",
293
+ "Create #{path} (with <div id=\"root\"></div>, <%= ruact_js_assets %> and <%= ruact_head_assets %>), " \
294
+ "or set config.layout = \"#{Ruact::GEM_LAYOUT}\" to use the layout ruact ships."]
295
+ else
296
+ [:fail, "React shell missing from #{path.basename}",
297
+ "Create #{path}, or set config.layout = \"#{Ruact::GEM_LAYOUT}\" in config/initializers/ruact.rb " \
298
+ "to render ruact pages through the layout ruact ships."]
190
299
  end
300
+ end
301
+
302
+ # Story 17.0b — the CSS half of the asset contract.
303
+ #
304
+ # Vite records client-component stylesheets on the bootstrap manifest entry.
305
+ # If the build produced CSS and the layout never calls `ruact_head_assets`,
306
+ # that CSS is served and never referenced: styling that works in development
307
+ # and silently disappears in production.
308
+ #
309
+ # The decision is MECHANICAL — read the config, the manifest and the layout
310
+ # that renders — and never an inference at render time. Layout auto-detection
311
+ # was removed deliberately (see Ruact::Configuration#layout) and this must not
312
+ # reintroduce it: `LayoutSource.head_wired?` runs through `without_comments`,
313
+ # so a mention inside a comment does not read as wired.
314
+ def check_head_assets
315
+ entry = doctor_manifest_entry
316
+ return head_assets_unreadable_result if entry == :unreadable
317
+
318
+ kind, path = rendering_layout
319
+ return head_assets_unbuilt_result(path) if entry.nil? && kind == :app && !head_wired_file?(path)
320
+
321
+ css = Array(entry && entry["css"])
322
+ return [:pass, "no client-component CSS in the build (nothing to link)"] if css.empty?
323
+
324
+ return [:pass, "client-component CSS present; the built-in shell links it"] if kind == :shell
325
+ return [:pass, "client-component CSS is linked by ruact's layout"] if kind == :gem
326
+ return head_assets_no_layout_result(path) if kind == :missing
327
+ return head_assets_missing_result(css.length, path) unless head_wired_file?(path)
328
+
329
+ [:pass, "client-component CSS is linked (ruact_head_assets in #{path.basename})"]
330
+ end
331
+
332
+ def head_wired_file?(path)
333
+ Ruact::LayoutSource.head_wired?(File.read(path))
334
+ end
335
+
336
+ # No build yet — the normal state in development with the Vite dev server,
337
+ # which injects component CSS itself. A layout of the app's own that never
338
+ # calls the helper passes there and loses that CSS in production, which is
339
+ # exactly what this check exists to catch: a warning, not a pass.
340
+ def head_assets_unbuilt_result(path)
341
+ [:warn,
342
+ "#{path.basename} does not call ruact_head_assets — client-component CSS will not reach production",
343
+ "Add <%= ruact_head_assets %> as the first thing inside <head> in #{path}, above your " \
344
+ "stylesheet_link_tag. There is no production build to check yet, so this is a warning; with " \
345
+ "a build that emits component CSS it is a failure."]
346
+ end
191
347
 
192
- content = File.read(path)
193
- has_root = Ruact::LayoutSource.root?(content)
194
- has_assets = Ruact::LayoutSource.wired?(content)
195
- opted_in = Ruact.config.layout != false
348
+ def head_assets_no_layout_result(path)
349
+ [:fail,
350
+ "the build emits client-component CSS but #{path.basename} does not exist " \
351
+ "(Ruact.config.layout points at it)",
352
+ "Ruact.config.layout points at #{path}, which is not there. Create it (or set " \
353
+ "config.layout = \"#{Ruact::GEM_LAYOUT}\") and add <%= ruact_head_assets %> inside its <head> — " \
354
+ "otherwise the stylesheets Vite built for your client components are served and never referenced."]
355
+ end
196
356
 
197
- return layout_unwired_result unless has_root && has_assets
198
- return layout_not_opted_in_result unless opted_in
357
+ # An unreadable manifest is NOT "nothing to link": the same file is parsed at
358
+ # render time, where a parse error raises. Reporting :pass here would mean the
359
+ # doctor is green on an app that 500s.
360
+ def head_assets_unreadable_result
361
+ [:fail,
362
+ "public/assets/.vite/manifest.json exists but is not valid JSON — rebuild your assets",
363
+ "Rebuild your assets (npm run build). ruact reads this file at render time, so a " \
364
+ "truncated or corrupt manifest raises there rather than degrading."]
365
+ end
199
366
 
200
- [:pass, "layout owns the document (React root + ruact_js_assets, config.layout on)"]
367
+ # The file goes in the MESSAGE, not only in the remediation: `Doctor#run`
368
+ # prints `message` alone, so anything a reader needs in the terminal has to
369
+ # be there. (Story 5.4 found the same asymmetry; the remediation reaches
370
+ # `-- --json` only.)
371
+ def head_assets_missing_result(count, path)
372
+ [:fail,
373
+ "the build emits #{count} client-component stylesheet(s) that nothing links " \
374
+ "— add <%= ruact_head_assets %> to #{path.basename}",
375
+ "Add <%= ruact_head_assets %> as the first thing inside <head> in #{path}, " \
376
+ "ABOVE your stylesheet_link_tag so your own CSS is loaded last and wins ties " \
377
+ "(ruact never edits your layout). " \
378
+ "Without it that CSS is built and served but never referenced - styling that works in " \
379
+ "development and vanishes in production."]
201
380
  end
202
381
 
203
- def layout_unwired_result
204
- [:fail, "layout is missing the React root and/or the ruact_js_assets call",
205
- "Add <%= ruact_js_assets %> next to <div id=\"root\"></div> in " \
206
- "app/views/layouts/application.html.erb (or re-run rails generate ruact:install). " \
207
- "Without both, ruact renders its built-in shell and your app's CSS never reaches the page."]
382
+ # The bootstrap manifest entry, or nil when there is no build to read. Kept
383
+ # here rather than reaching into the view helper's private lookup.
384
+ def doctor_manifest_entry
385
+ manifest_path = Rails.root.join("public", "assets", ".vite", "manifest.json")
386
+ return nil unless File.exist?(manifest_path)
387
+
388
+ JSON.parse(File.read(manifest_path))[Ruact.bootstrap_virtual_id]
389
+ rescue JSON::ParserError
390
+ :unreadable
391
+ end
392
+
393
+ # The exact lines the layout lacks, named in the MESSAGE (`Doctor#run` prints
394
+ # only the message; the remediation reaches `-- --json` alone).
395
+ def missing_layout_pieces(content)
396
+ missing = []
397
+ missing << %(<div id="root"></div>) unless Ruact::LayoutSource.root?(content)
398
+ missing << "<%= ruact_js_assets %>" unless Ruact::LayoutSource.wired?(content)
399
+ missing
400
+ end
401
+
402
+ def layout_unwired_result(path, missing)
403
+ [:fail, "#{path.basename} is missing #{missing.join(' and ')}",
404
+ "Add #{missing.join(' and ')} to #{path} (the root div in <body>, the helper right after it). " \
405
+ "#{layout_alternative(path)}Without both, ruact renders its built-in shell and your app's CSS never " \
406
+ "reaches the page."]
407
+ end
408
+
409
+ # Suggesting `config.layout = "ruact"` to an app whose file IS the ejected
410
+ # `ruact` layout would be advice to change nothing.
411
+ def layout_alternative(path)
412
+ return "" if path.basename.to_s == "#{Ruact::GEM_LAYOUT}.html.erb"
413
+
414
+ "Or set config.layout = \"#{Ruact::GEM_LAYOUT}\" to use the layout ruact ships. "
208
415
  end
209
416
 
210
417
  def layout_not_opted_in_result
211
418
  [:warn, "layout is ready but Ruact.config.layout is false",
212
419
  "Your layout calls ruact_js_assets, but ruact is still rendering its built-in shell " \
213
- "(which has no stylesheet). Set `config.layout = true` in config/initializers/ruact.rb."]
420
+ "(which has none of your stylesheets). Set `config.layout = true` in config/initializers/ruact.rb " \
421
+ "to render through it, or `config.layout = \"#{Ruact::GEM_LAYOUT}\"` for the layout ruact ships."]
214
422
  end
215
423
 
216
424
  def check_streaming
@@ -3,10 +3,11 @@
3
3
  module Ruact
4
4
  # Answers one question in ONE place: does this layout actually wire ruact up?
5
5
  #
6
- # Two callers need that answer and must not disagree about it — the runtime
6
+ # Three callers need that answer and must not disagree about it — the runtime
7
7
  # (`Ruact::Controller::DocumentRendering`, deciding whether it is safe to
8
- # render through the host layout) and `ruact:install` (deciding whether the
9
- # layout still needs migrating). When they each carried their own notion of
8
+ # render through the host layout), `ruact:install` (reading an app-owned
9
+ # layout to print the lines it lacks — it never writes one) and
10
+ # `ruact:doctor`. When they each carried their own notion of
10
11
  # "present", they drifted: the generator skipped a layout as already-migrated
11
12
  # on a `<%# TODO: add ruact_js_assets %>` comment, while the runtime read the
12
13
  # same layout as unwired. Both were string-matching a NAME where only a CALL
@@ -20,6 +21,9 @@ module Ruact
20
21
  # an earlier version of this pattern missed.
21
22
  ERB_COMMENT = /<%-?#.*?-?%>/m
22
23
 
24
+ # Either comment syntax, matched in one left-to-right pass.
25
+ ANY_COMMENT = /<!--.*?-->|<%-?#.*?-?%>/m
26
+
23
27
  # An ERB OUTPUT tag calling the helper: `<%= ruact_js_assets %>` and the raw
24
28
  # `<%== ... %>` form, with or without arguments or surrounding whitespace.
25
29
  # Scanning stops at the tag's own `%>` rather than forbidding `%` outright —
@@ -28,6 +32,12 @@ module Ruact
28
32
  # merely follows some other tag.
29
33
  ASSETS_CALL = /<%=+(?:(?!%>).)*?\bruact_js_assets\b/m
30
34
 
35
+ # Story 17.0b — the `<head>` half. A sibling recogniser, not a second
36
+ # mechanism: it runs through the same `without_comments` as the others,
37
+ # because a mention inside a comment reading as "already wired" is the exact
38
+ # failure that got layout auto-detection removed.
39
+ HEAD_ASSETS_CALL = /<%=+(?:(?!%>).)*?\bruact_head_assets\b/m
40
+
31
41
  # The React mount target, as an attribute rather than as a substring. The
32
42
  # lookbehind is what stops `data-id="root"` (and any other `*-id`) from
33
43
  # counting: those are not the mount point, and a document that has one but
@@ -35,15 +45,17 @@ module Ruact
35
45
  # valid HTML and is accepted.
36
46
  ROOT_ATTRIBUTE = /(?<![-\w])id\s*=\s*(?:"root"|'root'|root(?=[\s>]))/
37
47
 
38
- # The whole element, for a generator that needs something to inject AFTER.
39
- ROOT_ELEMENT = %r{<div\s[^>]*#{ROOT_ATTRIBUTE.source}[^>]*>\s*</div>}
40
-
41
48
  class << self
42
49
  # Does this ERB source actually CALL `ruact_js_assets`?
43
50
  def wired?(source)
44
51
  without_comments(source).match?(ASSETS_CALL)
45
52
  end
46
53
 
54
+ # Whether the layout calls `ruact_head_assets` (Story 17.0b).
55
+ def head_wired?(source)
56
+ without_comments(source).match?(HEAD_ASSETS_CALL)
57
+ end
58
+
47
59
  # Does this markup carry a React mount target? Used on RENDERED HTML by
48
60
  # the runtime and on ERB source by the generator; the attribute shape is
49
61
  # the same either way.
@@ -51,8 +63,18 @@ module Ruact
51
63
  markup.to_s.match?(ROOT_ATTRIBUTE)
52
64
  end
53
65
 
66
+ # Strips BOTH comment syntaxes a layout can hide a call in.
67
+ #
68
+ # This used to strip only the ERB form, which left `<!-- <%= ruact_js_assets %> -->`
69
+ # reading as a live call: the generator skipped the migration and the doctor
70
+ # passed, on a layout emitting nothing. Story 17.0b found it on the new
71
+ # `head_wired?` and it was equally true of `wired?`.
72
+ #
73
+ # ONE alternation rather than two passes, so a left-to-right scan closes
74
+ # whichever comment opened first — running the HTML pass first let a `<!--`
75
+ # living inside `<%# ... %>` swallow through to a later `-->`.
54
76
  def without_comments(source)
55
- source.to_s.gsub(ERB_COMMENT, "")
77
+ source.to_s.gsub(ANY_COMMENT, "")
56
78
  end
57
79
  end
58
80
  end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Ruact
6
+ # Story 17.0f (FR117) — the document belongs to whoever rendered it.
7
+ #
8
+ # The ruact router intercepts every same-origin link and form: it cannot know,
9
+ # from the browser, whether the destination is a ruact page. Asked with
10
+ # `Accept: text/x-component`, an ordinary Rails action answers 200 HTML, which
11
+ # the router cannot render — a dead click, silently (spike 2026-09-12, S2). And
12
+ # a form that crossed to an ordinary action had already RUN it by the time the
13
+ # router saw the HTML, so it could not be retried (S9b).
14
+ #
15
+ # The server can know, before anything runs. For a request the router sent
16
+ # (`Ruact-Request: 1`, a header only the router sends), {Middleware} asks
17
+ # {Classifier} whether the route it would reach is a ruact page. If not, it
18
+ # answers `Ruact-Boundary: native` WITHOUT calling the app, and the router
19
+ # hands the navigation — or the form submission — to the browser. The action
20
+ # then runs exactly once, natively, and its real response (a 422 with the
21
+ # validation errors included) reaches the user.
22
+ #
23
+ # Measured in the 2026-09-16 spike (playgrounds/nav-islands): ~0.1 ms median
24
+ # per router request; every fixture below held.
25
+ module NavigationBoundary
26
+ # Response header the router reads.
27
+ HEADER = "ruact-boundary"
28
+ # Its only value today: "not a ruact page — let the browser do it".
29
+ NATIVE = "native"
30
+
31
+ # The Rack middleware the Railtie installs. Remove it with
32
+ # `config.middleware.delete Ruact::NavigationBoundary::Middleware`.
33
+ class Middleware
34
+ def initialize(app, classifier: Classifier.new)
35
+ @app = app
36
+ @classifier = classifier
37
+ end
38
+
39
+ def call(env)
40
+ return @app.call(env) unless env["HTTP_RUACT_REQUEST"] == "1"
41
+ return native_response if @classifier.classify(env) == :native
42
+
43
+ @app.call(env)
44
+ end
45
+
46
+ private
47
+
48
+ # `Vary`, because the same URL answers differently with and without the
49
+ # router's header; `no-store`, because this answer is about routing, not
50
+ # content, and must never be served from a cache to a browser navigation.
51
+ #
52
+ # Built through Rack's own header class so a later middleware looking up
53
+ # `Cache-Control` finds this one on Rack 2 (Rails 7.0) as well as Rack 3.
54
+ def native_response
55
+ [200, native_headers, []]
56
+ end
57
+
58
+ def native_headers
59
+ headers = defined?(Rack::Headers) ? Rack::Headers.new : Rack::Utils::HeaderHash.new
60
+ headers[HEADER] = NATIVE
61
+ headers["content-type"] = "text/plain; charset=utf-8"
62
+ headers["cache-control"] = "no-store"
63
+ headers["vary"] = "Ruact-Request"
64
+ headers
65
+ end
66
+ end
67
+
68
+ # Decides, from the route table alone, where a request would land.
69
+ #
70
+ # Mirrors what Rails' own router does on `serve` — not just `recognize`:
71
+ # lambda and object constraints are evaluated IN ROUTE ORDER, with the same
72
+ # cascade to the next matching route (`recognize` alone skips them; they
73
+ # live on `Mapper::Constraints` and only run on `serve`). Descends into
74
+ # mounted engines. Nothing it calls executes an action.
75
+ #
76
+ # Ties break towards letting the request through: a wrong :native would
77
+ # skip ruact on a ruact page, a wrong :ruact would bring the dead click back,
78
+ # but :pass only costs what ruact cost before this existed.
79
+ class Classifier
80
+ # @param router [ActionDispatch::Journey::Router, nil] defaults to the
81
+ # application's, resolved lazily (routes reload in development)
82
+ def initialize(router = nil)
83
+ @router = router
84
+ end
85
+
86
+ # @param env [Hash] the Rack env
87
+ # @return [Symbol] `:ruact`, `:native`, or `:pass` (could not tell)
88
+ def classify(env)
89
+ request = ActionDispatch::Request.new(env.dup)
90
+ verdict_in(@router || application_router, request) || :pass
91
+ rescue Ruact::ConfigurationError => e
92
+ # A misdeclared `ruact_pages` is the app's bug to see, not a routing
93
+ # doubt: say so where it shows, then let the request through.
94
+ Rails.logger&.warn("[ruact] #{e.message}")
95
+ :pass
96
+ rescue StandardError => e
97
+ Rails.logger&.debug do
98
+ "[ruact] navigation boundary could not classify #{env['PATH_INFO']}: #{e.class}: #{e.message}"
99
+ end
100
+ :pass
101
+ end
102
+
103
+ private
104
+
105
+ # Rails 8 routes load lazily in development and test: the route set loads
106
+ # itself on `call`, but not when its `router` is read — and this runs
107
+ # BEFORE the app is called, so the first request after a boot could see
108
+ # an empty table.
109
+ def application_router
110
+ app = Rails.application
111
+ app.reload_routes_unless_loaded if app.respond_to?(:reload_routes_unless_loaded)
112
+ app.routes.router
113
+ end
114
+
115
+ # @return [Symbol, nil] nil when nothing in THIS router answers — the
116
+ # caller keeps scanning, the way Rails cascades past an engine (or a Rack
117
+ # app at "/") whose own routes do not match
118
+ def verdict_in(router, request, in_engine: false)
119
+ root_app = false
120
+ router.recognize(request) do |route, params|
121
+ app = route.app
122
+ if app.is_a?(ActionDispatch::Routing::Mapper::Constraints)
123
+ request.path_parameters = params
124
+ next unless app.matches?(request) # `serve` would cascade to the next route
125
+
126
+ app = app.app
127
+ end
128
+
129
+ if mounted_rack_app?(route, app)
130
+ # A Rack app mounted at "/" (Grape, Sinatra) passes on what it does
131
+ # not know with `X-Cascade: pass`, and Rails moves on: keep looking.
132
+ # Mounted anywhere else, it owns its prefix — Sidekiq at /sidekiq is
133
+ # not reclassified by a catch-all route drawn after it.
134
+ if route.path.spec.to_s == "/"
135
+ root_app = true
136
+ next
137
+ end
138
+ return :native
139
+ end
140
+
141
+ verdict = verdict_for(route, app, request, params, in_engine: in_engine)
142
+ return verdict if verdict # an engine with no matching route cascades: keep looking
143
+ end
144
+ root_app ? :native : nil
145
+ end
146
+
147
+ def mounted_rack_app?(route, app)
148
+ !route.dispatcher? && !app.is_a?(ActionDispatch::Routing::Redirect) && !engine?(app)
149
+ end
150
+
151
+ def engine?(app)
152
+ app.respond_to?(:routes) && app.routes.respond_to?(:router)
153
+ end
154
+
155
+ def verdict_for(route, app, request, params, in_engine:)
156
+ return action_verdict(request, params[:controller], params[:action], in_engine) if route.dispatcher?
157
+ # A route that redirects answers the router itself; there is no "no
158
+ # match" for it, so an unclassifiable one passes rather than cascading.
159
+ return redirect_verdict(app, request, params) || :pass if app.is_a?(ActionDispatch::Routing::Redirect)
160
+
161
+ # Inside `recognize`'s block the request's path_info is already relative
162
+ # to the mount point, which is what the engine's own router expects.
163
+ verdict_in(app.routes.router, request, in_engine: true)
164
+ end
165
+
166
+ # Same origin: the fetch follows the redirect and the target is
167
+ # classified when it arrives. Another origin: the fetch cannot follow it
168
+ # (CORS), so the browser has to — native.
169
+ #
170
+ # A 307/308 on a form re-sends the POST to the target; if that target is
171
+ # not ruact, the router could only answer with a GET there and the POST
172
+ # would never run. The browser's own submit follows it correctly: native.
173
+ #
174
+ # A `redirect { |params, req| … }` BLOCK is application code — it may
175
+ # query the database — and is never run here: it passes through.
176
+ EVALUATED_REDIRECTS = %w[ActionDispatch::Routing::PathRedirect ActionDispatch::Routing::OptionRedirect].freeze
177
+ private_constant :EVALUATED_REDIRECTS
178
+
179
+ def redirect_verdict(redirect, request, params)
180
+ return :native if [307, 308].include?(redirect.status) && !(request.get? || request.head?)
181
+ return :pass unless EVALUATED_REDIRECTS.include?(redirect.class.name)
182
+
183
+ target = URI.parse(redirect.path(params, request).to_s)
184
+ same_origin = target.host == request.host && (target.port || request.port) == request.port
185
+ return :pass if target.host.nil? || same_origin
186
+
187
+ :native
188
+ rescue StandardError
189
+ :pass
190
+ end
191
+
192
+ # GET/HEAD: ruact renders the page only when `default_render` would —
193
+ # the same class-level predicate, never a copy of it. That is what keeps a
194
+ # Devise-like controller (it inherits the app's ruact controller, its
195
+ # templates live in the engine) native.
196
+ #
197
+ # Anything else: including the concern is enough — unless the controller
198
+ # declares its pages (`ruact_pages`, Story 17.0g), and then only for the
199
+ # declared actions. A `create` has no template, and answers the router
200
+ # with a Flight redirect row (Ruact::Controller#redirect_to) — the Story
201
+ # 13.3 redirect-back has to stay in place, not become a full page load.
202
+ #
203
+ # Inside a mounted ENGINE, a non-GET is native even when its controller
204
+ # inherits the app's ruact controller: that is Devise's shape, and its
205
+ # `destroy` answers a non-navigational request with a bare 204 — the user
206
+ # is signed out and the router has nothing to render. An engine's actions
207
+ # speak the engine's protocol, not ruact's. Its GET pages still count when
208
+ # the APP provides the template (the way apps override engine views).
209
+ #
210
+ # The same holds for a controller a GEM defines even when its routes are
211
+ # drawn into the app's own table — `devise_for` mounts no engine, and
212
+ # `Devise::SessionsController#destroy` is the sign-out the rule exists
213
+ # for. So a non-GET is ruact only when the controller is the app's own:
214
+ # defined under `Rails.root/app`.
215
+ def action_verdict(request, controller, action, in_engine)
216
+ klass = "#{controller.to_s.camelize}Controller".safe_constantize
217
+ return :pass unless klass.is_a?(Class)
218
+ return :native unless klass.include?(Ruact::Controller)
219
+ return non_get_verdict(klass, action, in_engine) unless request.get? || request.head?
220
+
221
+ klass.ruact_page?(action) ? :ruact : :native
222
+ end
223
+
224
+ # Story 17.0g — a controller that DECLARED its pages (`ruact_pages`) is ruact
225
+ # only for those: a `create` whose `new` is a plain Rails page re-renders
226
+ # plain HTML on a validation error, which the router could not show.
227
+ def non_get_verdict(klass, action, in_engine)
228
+ return :native if in_engine || !app_owned?(klass)
229
+ return :native if klass.__ruact_pages && !klass.ruact_page_action?(action)
230
+
231
+ :ruact
232
+ end
233
+
234
+ def app_owned?(klass)
235
+ file = Object.const_source_location(klass.name)&.first
236
+ !file.nil? && File.expand_path(file).start_with?("#{Rails.root.join('app')}/")
237
+ end
238
+ end
239
+ end
240
+ end
data/lib/ruact/railtie.rb CHANGED
@@ -16,6 +16,36 @@ module Ruact
16
16
  require_relative "routing"
17
17
  end
18
18
 
19
+ # Story 17.0b — make the gem's own layout (`layouts/ruact`) findable by name.
20
+ #
21
+ # APPEND, not prepend: the gem's views go BEHIND the app's and every
22
+ # engine's, so an app that ejects the layout (`rails generate ruact:layout`)
23
+ # wins by view-path order and `layouts/application` always stays the app's.
24
+ # Shadowing the app's layout was measured and rejected (spike 2026-09-10:
25
+ # every page loaded React and lost the app's <title>), and the prepend
26
+ # version failed SILENTLY from this very hook — which is why the order this
27
+ # produces is asserted by a spec that boots a real app through this Railtie
28
+ # (spec/ruact/gem_layout_boot_spec.rb), in development and production.
29
+ #
30
+ # `respond_to?` mirrors Rails' own `add_view_paths`: the hook also fires for
31
+ # controller classes that carry no view paths.
32
+ initializer "ruact.view_paths" do
33
+ ActiveSupport.on_load(:action_controller) do
34
+ append_view_path(Ruact.views_path) if respond_to?(:append_view_path)
35
+ end
36
+ end
37
+
38
+ # Story 17.0f (FR117) — answer the ruact router before any action runs when
39
+ # the route it asked for is not a ruact page. See Ruact::NavigationBoundary.
40
+ #
41
+ # `use` appends: the middleware sits after Rack::MethodOverride when the app
42
+ # has it, so a form's `_method=delete` is already DELETE when the verb is
43
+ # read, and an app without it (API-shaped) does not fail to boot the way
44
+ # `insert_after Rack::MethodOverride` would.
45
+ initializer "ruact.navigation_boundary" do |app|
46
+ app.config.middleware.use Ruact::NavigationBoundary::Middleware
47
+ end
48
+
19
49
  rake_tasks { load File.expand_path("../tasks/ruact.rake", __dir__) }
20
50
 
21
51
  # Load the client manifest at boot (and on each code reload in development).