ruact 0.0.7 → 0.0.9

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/docs/internal/decisions/server-functions-api.md +55 -0
  4. data/lib/generators/ruact/install/install_generator.rb +378 -6
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
  6. data/lib/generators/ruact/install/templates/Procfile.dev.tt +3 -0
  7. data/lib/generators/ruact/install/templates/globals.css.tt +20 -0
  8. data/lib/generators/ruact/install/templates/initializer.rb.tt +9 -0
  9. data/lib/generators/ruact/install/templates/package.json.tt +7 -1
  10. data/lib/generators/ruact/install/templates/tsconfig.json.tt +18 -0
  11. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +10 -0
  12. data/lib/ruact/configuration.rb +73 -0
  13. data/lib/ruact/controller/document_rendering.rb +210 -0
  14. data/lib/ruact/controller.rb +14 -46
  15. data/lib/ruact/doctor.rb +102 -12
  16. data/lib/ruact/erb_preprocessor.rb +113 -0
  17. data/lib/ruact/errors.rb +16 -0
  18. data/lib/ruact/layout_source.rb +59 -0
  19. data/lib/ruact/manifest_resolver.rb +2 -2
  20. data/lib/ruact/serializable.rb +98 -6
  21. data/lib/ruact/server.rb +163 -0
  22. data/lib/ruact/server_functions/introspection.rb +81 -0
  23. data/lib/ruact/server_functions.rb +26 -4
  24. data/lib/ruact/testing/component_query.rb +113 -0
  25. data/lib/ruact/testing/flight_extractor.rb +221 -0
  26. data/lib/ruact/testing/flight_structure_diff.rb +267 -0
  27. data/lib/ruact/testing/flight_wire_parser.rb +138 -0
  28. data/lib/ruact/testing.rb +90 -0
  29. data/lib/ruact/version.rb +1 -1
  30. data/lib/ruact/view_helper.rb +10 -1
  31. data/lib/ruact.rb +1 -0
  32. data/lib/tasks/ruact.rake +55 -2
  33. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +3 -0
  34. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +3 -0
  35. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +3 -0
  36. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +3 -0
  37. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +3 -0
  38. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +16 -0
  39. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +24 -0
  40. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +15 -0
  41. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +17 -0
  42. data/spec/ruact/controller_request_spec.rb +203 -0
  43. data/spec/ruact/doctor_spec.rb +222 -6
  44. data/spec/ruact/erb_preprocessor_spec.rb +145 -0
  45. data/spec/ruact/install_generator_spec.rb +727 -70
  46. data/spec/ruact/layout_source_spec.rb +108 -0
  47. data/spec/ruact/manifest_resolver_spec.rb +15 -0
  48. data/spec/ruact/scaffold_generator_spec.rb +14 -0
  49. data/spec/ruact/serializable_spec.rb +126 -0
  50. data/spec/ruact/server_bucket_request_spec.rb +291 -0
  51. data/spec/ruact/server_functions/introspection_spec.rb +135 -0
  52. data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
  53. data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
  54. data/spec/ruact/testing/no_production_load_spec.rb +41 -0
  55. data/spec/support/flight_wire_parser.rb +12 -126
  56. data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
  57. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
  58. data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
  59. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
  60. data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
  61. metadata +30 -4
@@ -0,0 +1,159 @@
1
+ <!-- ruact:begin -->
2
+ <!-- Managed by `rails generate ruact:install`. Re-run it with --force after a
3
+ gem upgrade to refresh ONLY this marked section; everything you write
4
+ outside the markers is preserved. Kept in sync manually with the online
5
+ copy at https://ruact.dev/llms.txt -->
6
+
7
+ # ruact — conventions for coding agents
8
+
9
+ ruact renders React Server Components from Rails. ERB templates ARE the server
10
+ components (serialized to the React Flight wire format), Rails routes are the
11
+ single source of truth, and there is no Node server — Vite only bundles the
12
+ client components. ruact is NOT Inertia, react-rails, or Next.js: do not
13
+ transplant their patterns. When unsure, read the generated code (see "Ground
14
+ truth" below) instead of guessing.
15
+
16
+ ## Mental model
17
+
18
+ - A page is a normal Rails controller action rendering a normal `.html.erb`.
19
+ - Interactive components live in `app/javascript/components/` as `"use client"`
20
+ files, mounted from ERB with a PascalCase self-closing tag:
21
+ `<LikeButton postId={@post.id} />`.
22
+ - Mutations are ordinary non-GET controller actions on a controller that does
23
+ `include Ruact::Server`. Reads are `Ruact::Query` classes in `app/queries/`.
24
+ - ruact generates a typed TypeScript module from the route table; React imports
25
+ server functions from `@/.ruact/server-functions`.
26
+
27
+ ## Server functions — the verb rule
28
+
29
+ `include Ruact::Server` makes a controller's routed non-GET actions
30
+ (`POST`/`PATCH`/`PUT`/`DELETE`) callable from React. GET page actions are
31
+ untouched. There is no per-action DSL and no synthetic endpoint: the action IS
32
+ the function, reached at its real route, visible in `bin/rails routes`. The JS
33
+ runtime forwards the CSRF token automatically; keep `include Ruact::Server`
34
+ AFTER `protect_from_forgery` in the ancestor chain.
35
+
36
+ ```ruby
37
+ class PostsController < ApplicationController
38
+ include Ruact::Server
39
+
40
+ def create
41
+ @post = Post.create!(post_params) # ivars become the JSON result
42
+ end
43
+ end
44
+ ```
45
+
46
+ ```tsx
47
+ import { createPost } from "@/.ruact/server-functions";
48
+ const { post } = await createPost({ post: { title: "Hi" } });
49
+ ```
50
+
51
+ ## Reads — queries and useQuery
52
+
53
+ Reads are public methods on `Ruact::Query` subclasses in `app/queries/`,
54
+ mounted in `config/routes.rb` with `ruact_queries CatalogQuery` (one named GET
55
+ route per public method, default prefix `/q`). Call them with `useQuery`:
56
+
57
+ ```tsx
58
+ import { categories, useQuery } from "@/.ruact/server-functions";
59
+ const { data, loading, error } = useQuery(categories);
60
+ ```
61
+
62
+ Query params come from the method's keyword arguments and accept ONLY
63
+ `string | number | boolean | null` (arrays/objects are rejected). Queries run
64
+ the host controller's callback chain (authentication etc.) before the query
65
+ object is instantiated.
66
+
67
+ ## Ground truth — read the generated file
68
+
69
+ `app/javascript/.ruact/server-functions.ts` is regenerated from the route
70
+ table (it is gitignored). It is the authoritative list of every accessor and
71
+ its typed params — READ THAT FILE instead of simulating the name generation.
72
+ Regenerate it after changing routes or queries:
73
+
74
+ bin/rails ruact:server_functions:generate
75
+
76
+ ## Five traps
77
+
78
+ 1. **Component tags take no children.** Client component tags are self-closing
79
+ ONLY: `<Card content={@body} />`, never `<Card>...</Card>`. Children (a
80
+ matching closing tag) fail LOUDLY with a `PreprocessorError` at preprocess
81
+ time — pass content as a prop instead. Sole exception: the built-in
82
+ `<Suspense fallback="...">...</Suspense>` pair.
83
+ 2. **`{}` props are Ruby, not JavaScript.** `<Badge label={@post.title} />`
84
+ evaluates `@post.title` as a Ruby expression in the ERB. No JS ternaries,
85
+ no `{...spread}`, no JSX children. Plain unbraced string props are not
86
+ supported either — always use braces.
87
+ 3. **One action, two response shapes.** A `Ruact::Server` non-GET action
88
+ answers JSON — its instance variables, or `204`, or `{"$redirect": path}` —
89
+ when the request's `Accept` header is exactly `application/json`, which is
90
+ what every generated-accessor call sends (including
91
+ `<form action={createPost}>`). Any other request shape renders normally
92
+ (a Flight stream for client-side navigation, an HTML page otherwise). You
93
+ cannot infer the response shape from the controller body alone — the
94
+ caller picks it.
95
+ 4. **`ruact_errors` requires fall-through.** On the `if @post.save ... else`
96
+ path, call `ruact_errors(@post)` and let the action END there — ruact's
97
+ implicit render injects `errors: { attribute: [messages] }` into the JSON.
98
+ An explicit `render` on that branch opts out of the injection. In the
99
+ redirect-back flow, `ruact_errors(@post)` then `redirect_to` carries the
100
+ errors through flash to the next render.
101
+ 5. **Accessor names are derived, not declared.** `posts#create` → `createPost`,
102
+ `posts#publish_all` → `publishAllPosts`; query methods camelCase the same
103
+ way (`search_users` → `searchUsers`). Collisions fail loudly at boot;
104
+ break an action tie with `ruact_function_name :action, as: "otherName"` —
105
+ a query tie by renaming the query method. Never guess a name — read
106
+ `app/javascript/.ruact/server-functions.ts`.
107
+
108
+ ## Serialization is an allowlist
109
+
110
+ Only the fields you name cross to the client. `include Ruact::Serializable` +
111
+ `ruact_props :id, :title` is that allowlist — on a PORO OR an ActiveRecord model
112
+ (lazy AR readers checked on first serialize, not class-load; a typo still raises):
113
+
114
+ ```ruby
115
+ class Post < ApplicationRecord
116
+ include Ruact::Serializable
117
+ ruact_props :id, :title # other columns never cross
118
+ end
119
+ ```
120
+
121
+ The manual row hash `{ id: p.id, title: p.title }` is the equivalent for a
122
+ no-model shape. An object with no allowlist warns + falls back to `as_json` in
123
+ dev; production (`strict_serialization`) raises, as does `as_json` returning `self`.
124
+
125
+ ## Record references — signed and explicit
126
+
127
+ Do not put raw record ids in props and trust them later. Mint a scoped signed
128
+ token and resolve it back:
129
+
130
+ ```ruby
131
+ token = Ruact.signed_global_id(@post, for: :editing, expires_in: 1.hour)
132
+ post = Ruact.locate_signed(params[:token], for: :editing) # tampered → 400
133
+ ```
134
+
135
+ The purpose (`for:`) is always required; the expiry must be explicit — a
136
+ duration, or a deliberate `expires_in: nil` for a non-expiring token.
137
+
138
+ ## Verify your work
139
+
140
+ - `bin/rails ruact:doctor` — checks install/config health; exits 1 on failure.
141
+ Append `-- --json` for the same checks as a machine-readable report.
142
+ - `bin/rails ruact:routes -- --json` — the accessor/route table (each accessor's
143
+ name, kind, verb, path, params) as JSON, from the same route table codegen
144
+ reads; bare `ruact:routes` prints a compact human table.
145
+ - Both `--json` outputs are EXPERIMENTAL — the shape may change; gate on their
146
+ `schema_version` field (currently `0`), do not treat it as a stable contract.
147
+ - `bin/rails ruact:server_functions:generate` — regenerates the TS module;
148
+ exits 1 on a naming collision or an invalid name.
149
+ - `bin/dev` — boots Rails AND Vite (both are required: Vite serves the client
150
+ components and writes the client manifest).
151
+ - If your app has TypeScript tooling configured, `npx tsc --noEmit`
152
+ type-checks call sites against the generated accessor types (a fresh
153
+ install does not ship a tsconfig).
154
+ - In request specs, `require "ruact/testing"` then assert a page rendered a
155
+ component — `expect(response).to have_ruact_component("PostList").with_props(including("posts"))`
156
+ — no browser. Function-call/query responses are JSON: `JSON.parse(response.body)`.
157
+
158
+ Docs: https://ruact.dev — compact agent reference at https://ruact.dev/llms.txt
159
+ <!-- ruact:end -->
@@ -1,2 +1,5 @@
1
1
  web: bin/rails server -p 3000
2
2
  vite: npm run dev
3
+ <% if shadcn? -%>
4
+ css: npx @tailwindcss/cli -i app/javascript/styles/globals.css -o app/assets/builds/tailwind.css --watch
5
+ <% end -%>
@@ -0,0 +1,20 @@
1
+ /* Tailwind entry — the SOURCE, not the asset the browser gets.
2
+ *
3
+ * Built into app/assets/builds/tailwind.css by the `css` process in
4
+ * Procfile.dev, which Propshaft then serves and the layout links:
5
+ *
6
+ * this file ──build──> app/assets/builds/tailwind.css
7
+ * ──Propshaft──> /assets/tailwind-<digest>.css
8
+ * ──stylesheet_link_tag in app/views/layouts──> the page
9
+ *
10
+ * `npx shadcn init` appends its design tokens (the CSS variables the
11
+ * components reference by name) below this import — that is why
12
+ * components.json points its `tailwind.css` at this file.
13
+ */
14
+ @import "tailwindcss";
15
+
16
+ /* Tailwind v4 declares its class scan here instead of in a config file.
17
+ Components carry most of the classes; the ERB views carry the page-level
18
+ ones, so both paths are needed. */
19
+ @source "../components";
20
+ @source "../../views";
@@ -1,6 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  Ruact.configure do |config|
4
+ # Render ruact pages through your app's own layout, so the document `<head>`
5
+ # is yours: `stylesheet_link_tag`, favicons, fonts, analytics and any gem that
6
+ # writes into `<head>` all reach a ruact page. Requires the layout to call
7
+ # `<%%= ruact_js_assets %>` (this generator adds it next to the React root).
8
+ #
9
+ # Set to false to use ruact's built-in minimal shell instead — it has no
10
+ # stylesheet slot, so your app's CSS will NOT reach a ruact-rendered page.
11
+ config.layout = true
12
+
4
13
  # Path to the react-client-manifest.json generated by the Vite plugin.
5
14
  # Defaults to Rails.root.join("public/react-client-manifest.json").
6
15
  # config.manifest_path = Rails.root.join("public", "react-client-manifest.json")
@@ -4,7 +4,8 @@
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
7
- "build": "vite build"
7
+ "build": "vite build"<% if shadcn? %>,
8
+ "build:css": "@tailwindcss/cli -i app/javascript/styles/globals.css -o app/assets/builds/tailwind.css --minify"<% end %>
8
9
  },
9
10
  "dependencies": {
10
11
  "react": "^19.0.0",
@@ -12,6 +13,11 @@
12
13
  },
13
14
  "devDependencies": {
14
15
  "@vitejs/plugin-react": "^4.3.4",
16
+ <% if shadcn? -%>
17
+ "@tailwindcss/cli": "^4.0.0",
18
+ "tailwindcss": "^4.0.0",
19
+ "tw-animate-css": "^1.0.0",
20
+ <% end -%>
15
21
  "vite": "^6.0.7"
16
22
  }
17
23
  }
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "baseUrl": ".",
13
+ "paths": {
14
+ "@/*": ["app/javascript/*"]
15
+ }
16
+ },
17
+ "include": ["app/javascript/**/*.ts", "app/javascript/**/*.tsx"]
18
+ }
@@ -47,6 +47,16 @@ module Ruact
47
47
  # List/DeleteDialog primitives. EVERY entry is a plain `npx shadcn add`
48
48
  # primitive — there is NO `data-table` recipe and NO `@tanstack/react-table`
49
49
  # dependency (Story 10.2b removed the engine; the List is a plain `table`).
50
+ # The COMPLETE primitive set — the union of everything any generated
51
+ # resource can import. `#required_shadcn_components` NARROWS this per
52
+ # resource (the input-family entries depend on the attribute types),
53
+ # but `ruact:install --shadcn` runs before any resource exists, so it
54
+ # prints this superset. A spec pins the narrowed list as a subset of
55
+ # this one, so the two cannot drift apart.
56
+ ALL_SHADCN_COMPONENTS = %w[
57
+ button input textarea switch select label badge table alert-dialog dropdown-menu
58
+ ].freeze
59
+
50
60
  def required_shadcn_components
51
61
  components = ["button"]
52
62
  components << "input" if form_uses_input?
@@ -24,6 +24,7 @@ module Ruact
24
24
  signed_global_id_default_purpose
25
25
  signed_global_id_default_expires_in
26
26
  shadcn_compatible_versions
27
+ layout
27
28
  ].freeze
28
29
 
29
30
  # @!attribute [r] manifest_path
@@ -135,6 +136,47 @@ module Ruact
135
136
  # suppress the warning — the documented "override" path.
136
137
  # @example Allow shadcn v3 once you have verified it
137
138
  # Ruact.configure { |c| c.shadcn_compatible_versions = [1, 2, 3] }
139
+ #
140
+ # @!attribute [r] layout
141
+ # @return [Boolean, String] Which document wrapper a ruact page's HTML
142
+ # response is rendered into. The Flight response shape
143
+ # (`text/x-component`) is never affected — this is only about the
144
+ # full-document render a browser gets on a normal navigation.
145
+ #
146
+ # - `false` (default) — render the gem's built-in minimal shell.
147
+ # - `true` — render through the controller's normal Rails layout.
148
+ # - a String — render through that named layout (e.g. `"ruact"`).
149
+ #
150
+ # The layout path exists because the document `<head>` belongs to the
151
+ # host app: `stylesheet_link_tag`, favicons, fonts, analytics and any
152
+ # `<head>`-writing gem only reach the page when Rails' own layout owns
153
+ # the document. The built-in shell carries no stylesheet slot, so under
154
+ # the `false` default a ruact page renders with no app CSS at all —
155
+ # which is why `rails generate ruact:install` writes `config.layout =
156
+ # true` into the generated initializer and adds `<%= ruact_js_assets %>`
157
+ # to your layout in the same run.
158
+ #
159
+ # **This setting is deliberately explicit — there is no auto-detection.**
160
+ # ruact used to try to infer whether your layout was ready by inspecting
161
+ # it. Deciding that reliably means answering "does this template call
162
+ # this method?", which cannot be done by pattern-matching a template
163
+ # language: three review rounds each found another shape that fooled it
164
+ # (a mention in a comment, a commented-out call, a trim-mode comment),
165
+ # and each wrong answer governed how every page in the app rendered.
166
+ # One explicit line is worth more than a clever guess here.
167
+ #
168
+ # A layout is ready when it calls `<%= ruact_js_assets %>` (which emits
169
+ # the React root's bootstrap entry tags and the per-render Flight
170
+ # payload) next to a `<div id="root"></div>`. If it does not, ruact says
171
+ # so loudly in development rather than serving a blank page, and
172
+ # `rails ruact:doctor` reports it.
173
+ # @note A ruact view is rendered in its own pass (it produces the component
174
+ # tree), so `content_for` declared inside the view does NOT reach the
175
+ # layout. Set document metadata from the controller instead.
176
+ # @example Let your layout own the document (what ruact:install writes)
177
+ # Ruact.configure { |c| c.layout = true }
178
+ # @example Use a dedicated layout for ruact pages only
179
+ # Ruact.configure { |c| c.layout = "ruact" }
138
180
  ATTRIBUTES.each do |attr|
139
181
  attr_reader attr
140
182
 
@@ -188,6 +230,7 @@ module Ruact
188
230
  @signed_global_id_default_purpose = nil
189
231
  @signed_global_id_default_expires_in = nil
190
232
  @shadcn_compatible_versions = [1, 2]
233
+ @layout = false
191
234
  end
192
235
  end
193
236
 
@@ -249,7 +292,37 @@ module Ruact
249
292
  when :query_route_prefix then validate_query_route_prefix!(value)
250
293
  when :query_parent_controller then validate_query_parent_controller!(value)
251
294
  when :shadcn_compatible_versions then validate_shadcn_compatible_versions!(value)
295
+ when :layout then validate_layout!(value)
296
+ end
297
+ end
298
+
299
+ # The value selects a render strategy by identity (`true` / `false`) or
300
+ # names a layout (String). Anything else would otherwise be treated as "not
301
+ # false" and reach `render_to_string(layout: <value>)`, where a Symbol
302
+ # layout name or a stray nil surfaces as a confusing first-request 500
303
+ # instead of a boot-time error. `nil` is rejected on purpose: "no layout" is
304
+ # spelled `false`, so a nil left over from a conditional in the initializer
305
+ # is a mistake, not a silent shell fallback.
306
+ def validate_layout!(value)
307
+ return if [true, false].include?(value)
308
+ return if value.is_a?(String) && !value.empty?
309
+
310
+ # `:auto` is rejected BY NAME because it used to be the default. An app
311
+ # carrying it forward from an older initializer must be told it is gone,
312
+ # not have it silently reinterpreted as "some truthy value".
313
+ if value == :auto
314
+ raise Ruact::ConfigurationError,
315
+ "Ruact::Configuration#layout no longer supports :auto — it inferred whether your " \
316
+ "layout was ready by inspecting it, which could not be done reliably. " \
317
+ "Set `true` to render through your app's layout (make sure it calls " \
318
+ "`<%= ruact_js_assets %>`), or `false` for ruact's built-in shell."
252
319
  end
320
+
321
+ raise Ruact::ConfigurationError,
322
+ "Ruact::Configuration#layout must be true, false, or a non-empty String layout name; " \
323
+ "got #{value.inspect} (#{value.class.name}). " \
324
+ "true renders through your app's layout (which must call ruact_js_assets); " \
325
+ "false uses ruact's built-in shell."
253
326
  end
254
327
 
255
328
  def validate_max_upload_bytes!(value)
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module Controller
5
+ # How a ruact response becomes a full HTML DOCUMENT — the wrapper a browser
6
+ # gets on a normal navigation, as opposed to the raw `text/x-component`
7
+ # Flight body an in-app navigation gets.
8
+ #
9
+ # Split out of `Ruact::Controller` because it answers one self-contained
10
+ # question ("who owns the `<head>`?") whose answer is load-bearing: the host
11
+ # app's layout does, because `stylesheet_link_tag`, favicons, fonts,
12
+ # analytics and every `<head>`-writing gem live there. `#ruact_html_shell`
13
+ # is the fallback for an app whose layout has not been migrated — it is
14
+ # deliberately minimal and has NO stylesheet slot.
15
+ module DocumentRendering
16
+ extend ActiveSupport::Concern
17
+
18
+ # Proof that a host layout called `ruact_js_assets`: the helper always
19
+ # emits the `__FLIGHT_DATA` bootstrap script when a payload is present,
20
+ # and `render_ruact_document` always supplies one. Matching on the payload
21
+ # script (rather than on the entry `<script src>`) keeps the check true in
22
+ # BOTH dev and production, whose entry tags differ.
23
+ RUACT_ASSETS_MARKER = "__FLIGHT_DATA"
24
+
25
+ # What counts as a mount target and what counts as a real call both live in
26
+ # {Ruact::LayoutSource}, shared with `ruact:install` so the runtime and the
27
+ # generator can never disagree about whether a layout is migrated.
28
+
29
+ private
30
+
31
+ # Emit the full HTML document a browser gets on a normal navigation.
32
+ #
33
+ # `Ruact.config.layout` decides, and it is EXPLICIT — ruact does not try
34
+ # to infer whether your layout is ready. It used to: three review rounds
35
+ # each found another template shape that fooled the inference (a mention
36
+ # in a comment, a commented-out call, a trim-mode comment), and each wrong
37
+ # answer governed how every page in the app rendered. Answering "does this
38
+ # template call this method?" is not something pattern-matching can do
39
+ # reliably, so the question is no longer asked. See
40
+ # Ruact::Configuration#layout.
41
+ #
42
+ # - `false` (the default) → the built-in shell. Byte-identical to ruact's
43
+ # behaviour before the layout path existed, with no detection in the
44
+ # way, so an app that has not opted in cannot be affected by any of this.
45
+ # - `true` / a String → render through the app's layout. `ruact:install`
46
+ # writes both halves of that opt-in together: `config.layout = true` in
47
+ # the initializer AND `<%= ruact_js_assets %>` in the layout.
48
+ #
49
+ # An opted-in layout that does not actually emit the assets would produce
50
+ # a blank page, so the rendered document is checked before it is
51
+ # committed: that is a configuration error, raised in development/test and
52
+ # logged-and-degraded in production rather than served to real traffic.
53
+ def render_ruact_document(payload)
54
+ layout = Ruact.config.layout
55
+ return render html: ruact_html_shell(payload).html_safe, layout: false if layout == false
56
+
57
+ unless ruact_layout_resolvable?(layout)
58
+ __ruact_handle_unready_layout(layout, :missing)
59
+ return render html: ruact_html_shell(payload).html_safe, layout: false
60
+ end
61
+
62
+ # Copied to the view by Rails' `view_assigns` plumbing (the name does not
63
+ # match the `/\A@_/` protected-ivar filter) — that is how the layout's
64
+ # zero-argument `ruact_js_assets` reaches THIS render's Flight payload.
65
+ @ruact_flight_payload = payload
66
+ document = render_to_string(html: "".html_safe, layout: layout)
67
+
68
+ if ruact_document_mountable?(document)
69
+ render html: document.html_safe, layout: false
70
+ else
71
+ __ruact_handle_unready_layout(layout, :unwired)
72
+ render html: ruact_html_shell(payload).html_safe, layout: false
73
+ end
74
+ ensure
75
+ remove_instance_variable(:@ruact_flight_payload) if instance_variable_defined?(:@ruact_flight_payload)
76
+ end
77
+
78
+ # A document is only usable if BOTH halves are present: the payload/bootstrap
79
+ # block AND something to mount into. Checking the assets alone accepts a
80
+ # layout that calls `ruact_js_assets` in `<head>` but never got the root div
81
+ # — React then boots with no mount target and the page is silently blank.
82
+ def ruact_document_mountable?(document)
83
+ document.include?(RUACT_ASSETS_MARKER) && Ruact::LayoutSource.root?(document)
84
+ end
85
+
86
+ # Is there a layout to render into at all? Asking FIRST matters: passing
87
+ # `layout: true` to a controller with no resolvable layout raises
88
+ # `ArgumentError("There was no default layout for ...")`, which would turn
89
+ # every page of an API-shaped or `layout false` controller into a 500 in
90
+ # an app that opted in globally. `_default_layout`'s `require_layout`
91
+ # argument defaults to false precisely so it can be used as a probe — it
92
+ # returns nil instead of raising, and honours `action_has_layout?`.
93
+ #
94
+ # `NameError` is deliberately NOT swallowed: `_default_layout` re-raises it
95
+ # as "Could not render layout: ..." to surface a broken layout resolver
96
+ # (`layout -> { MissingConstant::LAYOUT }`). Turning that into "no layout"
97
+ # would hide the developer's bug behind a silently degraded page.
98
+ def ruact_layout_resolvable?(layout)
99
+ return ruact_layout_exists?(layout) if layout.is_a?(String)
100
+
101
+ resolved = _default_layout(lookup_context, [:html], [])
102
+ return false if resolved.nil? || resolved == false
103
+ # `_default_layout` returns a Template for the conventional lookup but a
104
+ # bare String path for a controller-declared `layout "name"` — and it
105
+ # returns that String WITHOUT checking the template exists. Treating
106
+ # "not nil" as resolvable therefore sent a declared-but-missing layout
107
+ # into `render_to_string`, where it raised `MissingTemplate` instead of
108
+ # taking the intended degrade-to-shell path.
109
+ return ruact_layout_exists?(resolved) if resolved.is_a?(String)
110
+
111
+ true
112
+ rescue NameError
113
+ raise
114
+ rescue StandardError
115
+ false
116
+ end
117
+
118
+ def ruact_layout_exists?(name)
119
+ lookup_context.exists?(name.to_s.delete_prefix("layouts/"), ["layouts"])
120
+ end
121
+
122
+ def __ruact_handle_unready_layout(_layout, reason)
123
+ detail =
124
+ if reason == :missing
125
+ "no layout could be resolved for it"
126
+ else
127
+ "the layout it rendered emitted no `ruact_js_assets` output (or no " \
128
+ "`<div id=\"root\"></div>` to mount into), which would have been a blank page"
129
+ end
130
+
131
+ message = <<~MSG.strip
132
+ ruact: #{controller_path}##{action_name} fell back to ruact's built-in HTML shell — #{detail}.
133
+ `Ruact.config.layout` is set, so this is a configuration error, not a default:
134
+ the built-in shell has no stylesheet slot, so your app's CSS does not reach this page.
135
+ Add `<%= ruact_js_assets %>` next to the `<div id="root"></div>` in your layout
136
+ (`rails generate ruact:install` writes both; `rails ruact:doctor` reports what is missing),
137
+ or set `Ruact.configure { |c| c.layout = false }` to use the built-in shell deliberately.
138
+ MSG
139
+
140
+ # A MISSING layout is a legitimate per-controller choice — an API-shaped
141
+ # controller, or one that declared `layout false`, inside an app that
142
+ # opted in globally. Raising there would break a normal Rails pattern,
143
+ # so it degrades to the shell and says so where a developer will see it.
144
+ #
145
+ # An UNWIRED layout is different: the developer pointed ruact at a
146
+ # layout that cannot mount the app, which is their bug to see — loudly
147
+ # in development, and degraded (never blank) in production.
148
+ if reason == :missing
149
+ logger&.info(message) if __ruact_local_env?
150
+ return
151
+ end
152
+
153
+ raise Ruact::Error, message if __ruact_local_env?
154
+
155
+ logger&.error(message)
156
+ end
157
+
158
+ def __ruact_local_env?
159
+ Rails.env.development? || Rails.env.test?
160
+ rescue StandardError
161
+ false
162
+ end
163
+
164
+ def ruact_html_shell(flight_payload)
165
+ # Story 14.2 — the JS asset block (entry `<script>` tags + `__FLIGHT_DATA`)
166
+ # is delegated to the single `Ruact::ViewHelper#ruact_js_assets`
167
+ # implementation. The bootstrap entry script is a deferred ES module, so it
168
+ # runs after the inline `__FLIGHT_DATA` classic script has populated the
169
+ # queue regardless of source order — emitting the whole block in `<body>`
170
+ # is correct.
171
+ <<~HTML
172
+ <!DOCTYPE html>
173
+ <html lang="en">
174
+ <head>
175
+ <meta charset="UTF-8" />
176
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
177
+ #{ruact_csrf_meta_tag}
178
+ <title>Rails RSC</title>
179
+ </head>
180
+ <body>
181
+ <div id="root"></div>
182
+ #{ruact_js_assets(flight_payload)}
183
+ </body>
184
+ </html>
185
+ HTML
186
+ end
187
+
188
+ # Story 8.3 review R7 — emits `<meta name="csrf-token" content="...">`
189
+ # into the shell so the JS runtime's `<meta>` lookup can forward a
190
+ # valid `X-CSRF-Token` on every server-function (mutation) call. Without
191
+ # this, hosts that route `ruact_render` through the gem's HTML shell (the
192
+ # standard path) have no token in the document and the host's
193
+ # `protect_from_forgery` rejects every non-GET server function.
194
+ #
195
+ # Returns an empty string when CSRF protection isn't available
196
+ # (non-Rails specs, or hosts that have deliberately stripped
197
+ # `form_authenticity_token` from the controller surface).
198
+ def ruact_csrf_meta_tag
199
+ return "" unless respond_to?(:form_authenticity_token, true)
200
+
201
+ token = form_authenticity_token
202
+ return "" if token.nil? || token.empty?
203
+
204
+ %(<meta name="csrf-token" content="#{ERB::Util.html_escape(token)}" />)
205
+ rescue StandardError
206
+ ""
207
+ end
208
+ end
209
+ end
210
+ end