ruact 0.0.8 → 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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -1
  3. data/lib/generators/ruact/install/install_generator.rb +264 -5
  4. data/lib/generators/ruact/install/templates/Procfile.dev.tt +3 -0
  5. data/lib/generators/ruact/install/templates/globals.css.tt +20 -0
  6. data/lib/generators/ruact/install/templates/initializer.rb.tt +9 -0
  7. data/lib/generators/ruact/install/templates/package.json.tt +7 -1
  8. data/lib/generators/ruact/install/templates/tsconfig.json.tt +18 -0
  9. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +10 -0
  10. data/lib/ruact/configuration.rb +73 -0
  11. data/lib/ruact/controller/document_rendering.rb +210 -0
  12. data/lib/ruact/controller.rb +5 -46
  13. data/lib/ruact/doctor.rb +37 -5
  14. data/lib/ruact/layout_source.rb +59 -0
  15. data/lib/ruact/version.rb +1 -1
  16. data/lib/ruact/view_helper.rb +10 -1
  17. data/lib/ruact.rb +1 -0
  18. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +3 -0
  19. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +3 -0
  20. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +3 -0
  21. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +3 -0
  22. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +3 -0
  23. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +16 -0
  24. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +24 -0
  25. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +15 -0
  26. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +17 -0
  27. data/spec/ruact/controller_request_spec.rb +203 -0
  28. data/spec/ruact/doctor_spec.rb +81 -6
  29. data/spec/ruact/install_generator_spec.rb +442 -70
  30. data/spec/ruact/layout_source_spec.rb +108 -0
  31. data/spec/ruact/scaffold_generator_spec.rb +14 -0
  32. metadata +18 -4
@@ -115,6 +115,14 @@ module ControllerRequestSpecSupport
115
115
  get "/errors-demo/new", to: "controller_request_spec_support/errors_demo#new"
116
116
  post "/errors-demo/create", to: "controller_request_spec_support/errors_demo#create"
117
117
  post "/errors-demo/create_valid", to: "controller_request_spec_support/errors_demo#create_valid"
118
+ # The layout owns the document — a page rendered through a migrated
119
+ # host layout, and one through a layout that never calls
120
+ # `ruact_js_assets`.
121
+ get "/layout-demo/show", to: "controller_request_spec_support/layout_demo#show"
122
+ get "/unwired-layout-demo/show", to: "controller_request_spec_support/unwired_layout_demo#show"
123
+ get "/exploding-layout-demo/show", to: "controller_request_spec_support/exploding_layout_demo#show"
124
+ get "/rootless-layout-demo/show", to: "controller_request_spec_support/rootless_layout_demo#show"
125
+ get "/ghost-layout-demo/show", to: "controller_request_spec_support/ghost_layout_demo#show"
118
126
  end
119
127
  end
120
128
  end
@@ -186,6 +194,73 @@ module ControllerRequestSpecSupport
186
194
 
187
195
  def show; end
188
196
  end
197
+
198
+ # The layout owns the document. Both controllers declare a NAMED layout
199
+ # instead of relying on `layouts/application`, so adding them cannot change
200
+ # what every other controller in this file renders (the rest have no
201
+ # resolvable layout and must keep getting ruact's built-in shell).
202
+ class LayoutDemoController < ActionController::Base
203
+ include Ruact::Controller
204
+
205
+ append_view_path File.expand_path("../fixtures/story_7_9_views", __dir__)
206
+ layout "ruact_host"
207
+
208
+ def show
209
+ ruact_render
210
+ end
211
+ end
212
+
213
+ # An unmigrated layout that RAISES if rendered (it reads an ivar a ruact
214
+ # action never sets). With the default config ruact must never execute it.
215
+ class ExplodingLayoutDemoController < ActionController::Base
216
+ include Ruact::Controller
217
+
218
+ append_view_path File.expand_path("../fixtures/story_7_9_views", __dir__)
219
+ layout "exploding_host"
220
+
221
+ def show
222
+ ruact_render
223
+ end
224
+ end
225
+
226
+ # Declares a layout that does not exist. `_default_layout` hands back the
227
+ # String path WITHOUT checking, so "not nil" read as resolvable and the render
228
+ # raised MissingTemplate instead of degrading.
229
+ class GhostLayoutDemoController < ActionController::Base
230
+ include Ruact::Controller
231
+
232
+ append_view_path File.expand_path("../fixtures/story_7_9_views", __dir__)
233
+ layout "no_such_host"
234
+
235
+ def show
236
+ ruact_render
237
+ end
238
+ end
239
+
240
+ # A layout that calls `ruact_js_assets` but has no root div to mount into.
241
+ class RootlessLayoutDemoController < ActionController::Base
242
+ include Ruact::Controller
243
+
244
+ append_view_path File.expand_path("../fixtures/story_7_9_views", __dir__)
245
+ layout "rootless_host"
246
+
247
+ def show
248
+ ruact_render
249
+ end
250
+ end
251
+
252
+ # Same page, but through a layout that never calls `ruact_js_assets` — the
253
+ # state every app installed before the layout owned the document is in.
254
+ class UnwiredLayoutDemoController < ActionController::Base
255
+ include Ruact::Controller
256
+
257
+ append_view_path File.expand_path("../fixtures/story_7_9_views", __dir__)
258
+ layout "bare_host"
259
+
260
+ def show
261
+ ruact_render
262
+ end
263
+ end
189
264
  end
190
265
 
191
266
  # Story 10.0 — write the implicit-render template at file-load time (before the
@@ -251,6 +326,134 @@ module Ruact # rubocop:disable Style/OneClassPerFile
251
326
  end
252
327
  end
253
328
 
329
+ # The bug this closes: a ruact page could not carry ANY of the host app's
330
+ # CSS. `ruact_render` rendered the view with `layout: false` and then wrapped
331
+ # the payload in a hardcoded shell whose `<head>` has no stylesheet slot, so
332
+ # `stylesheet_link_tag` in the app's layout never reached the browser. The
333
+ # generated `--shadcn` scaffold was therefore unstyled by construction, and
334
+ # Epic 12 (`ruact_meta` → tags in `<head>`) had no surface to write into.
335
+ #
336
+ # The opt-in is EXPLICIT (`Ruact.config.layout`). ruact used to infer
337
+ # readiness from the layout itself; three review rounds each found another
338
+ # template shape that fooled the inference, so the question is no longer
339
+ # asked. These examples pin both halves: nothing happens without the config,
340
+ # and everything happens with it.
341
+ describe "the layout owns the document" do
342
+ def configure_layout(value)
343
+ Ruact.configure do |c|
344
+ c.manifest_path = ControllerRequestSpecSupport.manifest_path
345
+ c.layout = value
346
+ end
347
+ end
348
+
349
+ # The whole backward-compatibility story, and it is now structural rather
350
+ # than argued: with the default config ruact never looks at, resolves, or
351
+ # renders a layout at all.
352
+ context "with the default configuration (layout = false)" do
353
+ it "renders the built-in shell, exactly as before the layout path existed" do
354
+ get "/layout-demo/show"
355
+
356
+ expect(last_response.status).to eq(200)
357
+ expect(last_response.body).to include("Rails RSC")
358
+ expect(last_response.body).not_to include("host-app.css")
359
+ expect(last_response.body).to include("DemoButton")
360
+ end
361
+
362
+ # This layout raises if it is rendered (it reads an ivar a ruact action
363
+ # never sets) — the shape of a real pre-migration app. Nothing may
364
+ # execute it.
365
+ it "never executes an unmigrated layout, so a working page cannot become a 500" do
366
+ get "/exploding-layout-demo/show"
367
+
368
+ expect(last_response.status).to(eq(200),
369
+ "expected the layout NOT to be rendered; got " \
370
+ "#{last_response.status} body=#{last_response.body[0, 300]}")
371
+ expect(last_response.body).to include("Rails RSC")
372
+ end
373
+ end
374
+
375
+ context "when the app opts in (layout = true)" do
376
+ before { configure_layout(true) }
377
+
378
+ it "puts the host app's stylesheet on a ruact page (the CSS could not arrive before)" do
379
+ get "/layout-demo/show"
380
+
381
+ expect(last_response.status).to(eq(200),
382
+ "expected 200, got #{last_response.status} " \
383
+ "body=#{last_response.body[0, 400]}")
384
+ expect(last_response.body).to include('<link rel="stylesheet" href="/host-app.css" />')
385
+ end
386
+
387
+ it "keeps the host layout's own <title> instead of ruact's placeholder" do
388
+ get "/layout-demo/show"
389
+
390
+ expect(last_response.body).to include("<title>Host App Title</title>")
391
+ expect(last_response.body).not_to include("Rails RSC")
392
+ end
393
+
394
+ it "still ships the Flight payload and the component, through the layout's ruact_js_assets" do
395
+ get "/layout-demo/show"
396
+
397
+ expect(last_response.body).to include("__FLIGHT_DATA")
398
+ expect(last_response.body).to include("DemoButton")
399
+ end
400
+
401
+ it "does not apply the layout to a Flight request (the wire shape is unchanged)" do
402
+ get "/layout-demo/show", {}, { "HTTP_ACCEPT" => "text/x-component" }
403
+
404
+ expect(last_response.headers["Content-Type"]).to include("text/x-component")
405
+ expect(last_response.body).not_to include("host-app.css")
406
+ expect(last_response.body).to include("DemoButton")
407
+ end
408
+
409
+ # Opting in and pointing ruact at a layout that cannot mount the app is
410
+ # a configuration error, and silence would cost a blank page.
411
+ it "fails loudly when the opted-in layout never calls ruact_js_assets" do
412
+ expect { get "/unwired-layout-demo/show" }
413
+ .to raise_error(Ruact::Error, /ruact_js_assets/)
414
+ end
415
+
416
+ it "fails loudly when the opted-in layout has no root div to mount into" do
417
+ expect { get "/rootless-layout-demo/show" }
418
+ .to raise_error(Ruact::Error, /root/)
419
+ end
420
+
421
+ # A controller that declares a layout which does not exist: Rails'
422
+ # `_default_layout` returns the String path without checking it, so
423
+ # "resolvable" has to mean "the template is actually there".
424
+ it "degrades to the shell when a declared layout does not exist" do
425
+ get "/ghost-layout-demo/show"
426
+
427
+ expect(last_response.status).to(eq(200),
428
+ "expected a degrade, got #{last_response.status} " \
429
+ "body=#{last_response.body[0, 300]}")
430
+ expect(last_response.body).to include("Rails RSC")
431
+ expect(last_response.body).to include("DemoButton")
432
+ end
433
+
434
+ # `layout false` on one controller inside an app that opted in globally
435
+ # is a normal Rails pattern, not a mistake — it must degrade, not raise.
436
+ it "degrades to the shell for a controller with no resolvable layout" do
437
+ get "/demo/show"
438
+
439
+ expect(last_response.status).to eq(200)
440
+ expect(last_response.body).to include("Rails RSC")
441
+ expect(last_response.body).to include("DemoButton")
442
+ end
443
+ end
444
+
445
+ context "when a named layout is configured" do
446
+ before { configure_layout("ruact_host") }
447
+
448
+ it "renders every ruact page through that layout" do
449
+ get "/demo/show"
450
+
451
+ expect(last_response.body).to include("host-app.css")
452
+ expect(last_response.body).to include("DemoButton")
453
+ end
454
+ end
455
+ end
456
+
254
457
  describe "regression guard: render context reaches ViewHelper" do
255
458
  it "ViewHelper#__ruact_component__ does NOT raise the outside-flow error" do
256
459
  # If the handoff regresses, the request returns 500 with this exact
@@ -23,10 +23,20 @@ RSpec.describe Ruact::Doctor do
23
23
  File.write(dir.join("application_controller.rb"), content)
24
24
  end
25
25
 
26
- def make_layout(with_sentinel: true)
26
+ # `with_assets` is the second half of a migrated layout: the React root alone
27
+ # leaves ruact on its built-in (CSS-less) shell, so the two markers are
28
+ # independent inputs, not one sentinel.
29
+ def make_layout(with_sentinel: true, with_assets: true)
27
30
  dir = tmpdir.join("app", "views", "layouts")
28
31
  FileUtils.mkdir_p(dir)
29
- content = with_sentinel ? "<%# ruact: root %>\n<div id=\"root\"></div>\n" : "<body></body>\n"
32
+ content =
33
+ if !with_sentinel
34
+ "<body></body>\n"
35
+ elsif with_assets
36
+ "<%# ruact: root %>\n<div id=\"root\"></div>\n<%= ruact_js_assets %>\n"
37
+ else
38
+ "<%# ruact: root %>\n<div id=\"root\"></div>\n"
39
+ end
30
40
  File.write(dir.join("application.html.erb"), content)
31
41
  end
32
42
 
@@ -131,8 +141,11 @@ RSpec.describe Ruact::Doctor do
131
141
  describe "#check_layout (AC#1, #5)" do
132
142
  subject(:doctor) { described_class.new }
133
143
 
134
- context "when layout contains the React shell sentinel" do
135
- before { make_layout(with_sentinel: true) }
144
+ context "when the layout is wired and the app opted in" do
145
+ before do
146
+ make_layout(with_sentinel: true, with_assets: true)
147
+ Ruact.configure { |c| c.layout = true }
148
+ end
136
149
 
137
150
  it "returns :pass" do
138
151
  status, = doctor.send(:check_layout)
@@ -140,7 +153,60 @@ RSpec.describe Ruact::Doctor do
140
153
  end
141
154
  end
142
155
 
143
- context "when React shell sentinel is absent" do
156
+ # Both halves are silent when wrong, and they are DIFFERENT fixes — "add one
157
+ # line to the layout" versus "flip one setting" — so they report separately.
158
+ context "when the layout is wired but Ruact.config.layout is false" do
159
+ before do
160
+ make_layout(with_sentinel: true, with_assets: true)
161
+ Ruact.configure { |c| c.layout = false }
162
+ end
163
+
164
+ it "returns :warn naming the setting, not the layout" do
165
+ status, message, remediation = doctor.send(:check_layout)
166
+
167
+ expect(status).to eq(:warn)
168
+ expect(message).to include("config.layout is false")
169
+ expect(remediation).to include("config.layout = true")
170
+ end
171
+
172
+ it "does not fail the doctor run" do
173
+ expect(described_class::SUCCESS_STATUSES).to include(:warn)
174
+ end
175
+ end
176
+
177
+ context "when the layout has the React root but never calls ruact_js_assets" do
178
+ before do
179
+ make_layout(with_sentinel: true, with_assets: false)
180
+ Ruact.configure { |c| c.layout = true }
181
+ end
182
+
183
+ it "fails and names the helper to add" do
184
+ status, _message, remediation = doctor.send(:check_layout)
185
+
186
+ expect(status).to eq(:fail)
187
+ expect(remediation).to include("ruact_js_assets")
188
+ end
189
+ end
190
+
191
+ # The drift this shares with the runtime and the generator: a MENTION of the
192
+ # helper in a comment is not a call, and reporting :pass on one sent a
193
+ # developer looking anywhere but at the real problem.
194
+ context "when the layout only mentions the helper in a comment" do
195
+ before do
196
+ dir = tmpdir.join("app", "views", "layouts")
197
+ FileUtils.mkdir_p(dir)
198
+ File.write(dir.join("application.html.erb"),
199
+ "<%# ruact: root %>\n<div id=\"root\"></div>\n<%# TODO: add ruact_js_assets %>\n")
200
+ Ruact.configure { |c| c.layout = true }
201
+ end
202
+
203
+ it "does not report it as wired" do
204
+ status, = doctor.send(:check_layout)
205
+ expect(status).to eq(:fail)
206
+ end
207
+ end
208
+
209
+ context "when the layout has neither the root nor the helper" do
144
210
  before { make_layout(with_sentinel: false) }
145
211
 
146
212
  it "returns :fail" do
@@ -148,8 +214,17 @@ RSpec.describe Ruact::Doctor do
148
214
  expect(status).to eq(:fail)
149
215
  end
150
216
 
151
- it "message is 'React shell missing from application.html.erb'" do
217
+ it "names both halves, since either could be the missing one" do
152
218
  _, msg = doctor.send(:check_layout)
219
+ expect(msg).to include("React root").and include("ruact_js_assets")
220
+ end
221
+ end
222
+
223
+ context "when there is no application layout file at all" do
224
+ it "reports the file, not its contents" do
225
+ status, msg = doctor.send(:check_layout)
226
+
227
+ expect(status).to eq(:fail)
153
228
  expect(msg).to eq("React shell missing from application.html.erb")
154
229
  end
155
230
  end