ruact 0.0.11 → 0.0.12

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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -48
  3. data/CONTRIBUTING.md +233 -0
  4. data/README.md +14 -4
  5. data/RELEASING.md +125 -139
  6. data/lib/ruact/packaging.rb +68 -0
  7. data/lib/ruact/version.rb +1 -1
  8. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +321 -0
  9. data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
  10. data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
  11. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
  12. metadata +6 -107
  13. data/.codecov.yml +0 -31
  14. data/.github/workflows/ci.yml +0 -284
  15. data/.github/workflows/server-functions-bench.yml +0 -54
  16. data/.rubocop.yml +0 -107
  17. data/.rubocop_todo.yml +0 -63
  18. data/Rakefile +0 -10
  19. data/bench/server_functions_dispatch_bench.rb +0 -276
  20. data/bench/server_functions_dispatch_bench.results.md +0 -150
  21. data/docs/internal/README.md +0 -9
  22. data/docs/internal/decisions/server-functions-api.md +0 -2236
  23. data/spec/benchmarks/baseline.json +0 -12
  24. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -109
  25. data/spec/fixtures/flight/README.md +0 -136
  26. data/spec/fixtures/flight/array.txt +0 -1
  27. data/spec/fixtures/flight/as_json_object.txt +0 -2
  28. data/spec/fixtures/flight/bigint.txt +0 -1
  29. data/spec/fixtures/flight/boolean_false.txt +0 -1
  30. data/spec/fixtures/flight/boolean_true.txt +0 -1
  31. data/spec/fixtures/flight/client_component_with_props.txt +0 -2
  32. data/spec/fixtures/flight/client_reference.txt +0 -2
  33. data/spec/fixtures/flight/hash.txt +0 -1
  34. data/spec/fixtures/flight/infinity.txt +0 -1
  35. data/spec/fixtures/flight/nan.txt +0 -1
  36. data/spec/fixtures/flight/negative_infinity.txt +0 -1
  37. data/spec/fixtures/flight/nil.txt +0 -1
  38. data/spec/fixtures/flight/number_float.txt +0 -1
  39. data/spec/fixtures/flight/number_integer.txt +0 -1
  40. data/spec/fixtures/flight/react_element_no_props.txt +0 -1
  41. data/spec/fixtures/flight/redirect_row.txt +0 -1
  42. data/spec/fixtures/flight/serializable_object.txt +0 -2
  43. data/spec/fixtures/flight/string_basic.txt +0 -1
  44. data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
  45. data/spec/fixtures/flight/undefined.txt +0 -1
  46. data/spec/fixtures/readme/children-error.html.erb +0 -3
  47. data/spec/fixtures/readme/children-error.txt +0 -1
  48. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
  49. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
  50. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
  51. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
  52. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
  53. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
  54. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
  55. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
  56. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
  57. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
  58. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
  59. data/spec/readme_demo_message_spec.rb +0 -67
  60. data/spec/readme_spec.rb +0 -282
  61. data/spec/ruact/client_manifest_spec.rb +0 -270
  62. data/spec/ruact/component_contract_spec.rb +0 -119
  63. data/spec/ruact/configuration_spec.rb +0 -518
  64. data/spec/ruact/controller_request_spec.rb +0 -671
  65. data/spec/ruact/controller_spec.rb +0 -343
  66. data/spec/ruact/doctor_spec.rb +0 -769
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
  68. data/spec/ruact/erb_preprocessor_spec.rb +0 -361
  69. data/spec/ruact/errors_spec.rb +0 -93
  70. data/spec/ruact/flight/renderer_spec.rb +0 -133
  71. data/spec/ruact/flight/serializer_spec.rb +0 -494
  72. data/spec/ruact/html_converter_spec.rb +0 -375
  73. data/spec/ruact/install_generator_spec.rb +0 -1549
  74. data/spec/ruact/layout_source_spec.rb +0 -108
  75. data/spec/ruact/manifest_resolver_spec.rb +0 -174
  76. data/spec/ruact/query_request_spec.rb +0 -706
  77. data/spec/ruact/query_spec.rb +0 -105
  78. data/spec/ruact/railtie_spec.rb +0 -155
  79. data/spec/ruact/render_context_spec.rb +0 -58
  80. data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
  81. data/spec/ruact/render_pipeline_spec.rb +0 -928
  82. data/spec/ruact/scaffold_generator_spec.rb +0 -1849
  83. data/spec/ruact/serializable_spec.rb +0 -179
  84. data/spec/ruact/server_bucket_request_spec.rb +0 -785
  85. data/spec/ruact/server_function_name_spec.rb +0 -53
  86. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
  87. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
  88. data/spec/ruact/server_functions/codegen_spec.rb +0 -397
  89. data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
  90. data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
  91. data/spec/ruact/server_functions/introspection_spec.rb +0 -135
  92. data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
  93. data/spec/ruact/server_functions/query_context_spec.rb +0 -72
  94. data/spec/ruact/server_functions/query_source_spec.rb +0 -193
  95. data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
  96. data/spec/ruact/server_functions/rake_spec.rb +0 -86
  97. data/spec/ruact/server_functions/route_source_spec.rb +0 -202
  98. data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
  99. data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
  100. data/spec/ruact/server_rescue_request_spec.rb +0 -416
  101. data/spec/ruact/server_spec.rb +0 -179
  102. data/spec/ruact/server_upload_request_spec.rb +0 -311
  103. data/spec/ruact/signed_references_spec.rb +0 -164
  104. data/spec/ruact/string_distance_spec.rb +0 -38
  105. data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
  106. data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
  107. data/spec/ruact/testing/no_production_load_spec.rb +0 -41
  108. data/spec/ruact/validation_errors_spec.rb +0 -116
  109. data/spec/ruact/view_helper_spec.rb +0 -131
  110. data/spec/spec_helper.rb +0 -77
  111. data/spec/support/fixtures/pixel.png +0 -0
  112. data/spec/support/flight_wire_parser.rb +0 -21
  113. data/spec/support/flight_wire_parser_spec.rb +0 -93
  114. data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
  115. data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
  116. data/spec/support/rails_stub.rb +0 -115
@@ -1,671 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Story 7.9 — Bug 7.8-B regression spec.
4
- #
5
- # Exercises Ruact::Controller end-to-end through Rails 8's full controller
6
- # lifecycle (ActionController::Base#dispatch → ActionView::Base allocation →
7
- # template render → __ruact_component__ helper). The bug closed by this spec
8
- # was invisible to unit-only controller_spec.rb because that file stubs
9
- # render_to_string and never instantiates ActionView::Base.
10
- #
11
- # Rails request-cycle subsystems are loaded HERE (not in spec_helper.rb) so
12
- # the rest of the suite continues to use spec/support/rails_stub.rb without
13
- # paying the action_controller / action_view boot cost.
14
- require "action_controller/railtie"
15
- require "action_view/railtie"
16
-
17
- require "spec_helper"
18
-
19
- require "rack/test"
20
- require "tmpdir"
21
- require "fileutils"
22
- require "pathname"
23
- require "active_model"
24
-
25
- # Ruact::Controller is normally loaded by the Railtie's `ruact.load_controller`
26
- # initializer at app boot. This spec instantiates a Rails::Application but does
27
- # not depend on initializer ordering, so we load the concern explicitly.
28
- require "ruact/controller"
29
-
30
- # Test-support helpers and demo controller — top-level so the Rails routes
31
- # resolver finds them. Defined before the describe block so the controller
32
- # class exists when routes are appended.
33
- module ControllerRequestSpecSupport
34
- class << self
35
- attr_reader :manifest_path
36
-
37
- def app_class
38
- @app_class ||= build_app_class
39
- end
40
-
41
- # Story 10.0 — a writable on-disk app root so an implicit-`default_render`
42
- # controller's conventional template (`Rails.root/app/views/<ctrl>/<action>`)
43
- # exists. Unlike the `append_view_path` demo controllers (which call
44
- # `ruact_render` directly), the implicit path is what `ruact_template_exists?`
45
- # probes — the bug under test lives in `default_render`, never reached when a
46
- # template is only on an appended path. Memoized so `config.root` and every
47
- # `write_view` call share one directory.
48
- def app_root
49
- @app_root ||= Pathname.new(Dir.mktmpdir("ruact-story-10-0-root"))
50
- end
51
-
52
- # Story 10.0 — write a conventional template under the app root so Rails'
53
- # default view path picks it up and `ruact_template_exists?` returns true.
54
- # Called at file-load time (before `initialize!`) so the `app/views` dir
55
- # exists when Rails computes `paths["app/views"].existent`.
56
- def write_view(controller_path, action, erb)
57
- view_dir = File.join(app_root, "app", "views", controller_path)
58
- FileUtils.mkdir_p(view_dir)
59
- File.write(File.join(view_dir, "#{action}.html.erb"), erb)
60
- end
61
-
62
- def boot!
63
- return if @booted
64
-
65
- @tmpdir = Dir.mktmpdir("ruact-story-7-9")
66
- @manifest_path = File.join(@tmpdir, "react-client-manifest.json")
67
- File.write(@manifest_path, JSON.dump(
68
- "DemoButton" => {
69
- "id" => "/DemoButton.jsx",
70
- "name" => "DemoButton",
71
- "chunks" => ["/DemoButton.jsx"]
72
- }
73
- ))
74
-
75
- # Story 7.3: Ruact.config is frozen after the first configure block. The
76
- # spec_helper before hook resets it between examples; the describe's
77
- # per-example `before` re-primes the manifest_path. Here we just put the
78
- # initial state in place and cache the manifest at module level so it
79
- # survives subsequent config resets.
80
- Ruact.instance_variable_set(:@config, nil)
81
- Ruact.instance_variable_set(:@configured_at_least_once, false)
82
- Ruact.configure do |c|
83
- c.manifest_path = @manifest_path
84
- end
85
- Ruact.manifest # prime the cache
86
-
87
- app_class.instance.initialize!
88
- @booted = true
89
- end
90
-
91
- private
92
-
93
- def build_app_class
94
- app_root_path = app_root
95
- Class.new(Rails::Application) do
96
- # Story 10.0 — a real on-disk root so the implicit-render controller's
97
- # conventional `app/views` template is discoverable by `default_render`.
98
- config.root = app_root_path
99
- config.eager_load = false
100
- config.consider_all_requests_local = true
101
- config.action_controller.perform_caching = false
102
- config.action_dispatch.show_exceptions = :none
103
- config.logger = Logger.new(IO::NULL)
104
- config.active_support.deprecation = :silence
105
- config.secret_key_base = "x" * 64
106
- config.hosts.clear if config.respond_to?(:hosts)
107
-
108
- routes.append do
109
- get "/demo/show", to: "controller_request_spec_support/demo#show"
110
- # Story 10.0 — implicit-`default_render` page action (empty body), backed
111
- # by a conventional `Rails.root/app/views` template, for the non-HTML
112
- # Accept graceful-degradation matrix.
113
- get "/implicit-demo/show", to: "controller_request_spec_support/implicit_demo#show"
114
- # Story 13.3 (FR98) — Bucket-1 redirect-back round-trip routes.
115
- get "/errors-demo/new", to: "controller_request_spec_support/errors_demo#new"
116
- post "/errors-demo/create", to: "controller_request_spec_support/errors_demo#create"
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"
126
- end
127
- end
128
- end
129
- end
130
-
131
- # Story 13.3 (FR98) — an ActiveModel record with a presence validation for the
132
- # redirect-back round-trip demo controller below.
133
- class ErrorsDemoPost
134
- include ActiveModel::Model
135
- include ActiveModel::Attributes
136
-
137
- attribute :title, :string
138
-
139
- validates :title, presence: true
140
-
141
- def self.name = "ErrorsDemoPost"
142
- end
143
-
144
- # Story 13.3 (FR98) — the Bucket-1 (native form / navigation) Inertia-style
145
- # redirect-back demo: `create` registers errors via `ruact_errors` then
146
- # `redirect_to`s the form; the errors survive the Flight redirect in `flash`
147
- # and arrive as an `errors` prop on the re-rendered `new` page.
148
- class ErrorsDemoController < ActionController::Base
149
- include Ruact::Controller
150
-
151
- append_view_path File.expand_path("../fixtures/story_7_9_views", __dir__)
152
-
153
- def new
154
- ruact_render
155
- end
156
-
157
- def create
158
- post = ErrorsDemoPost.new(title: nil)
159
- post.valid? # false → populates errors
160
- ruact_errors(post)
161
- redirect_to "/errors-demo/new"
162
- end
163
-
164
- def create_valid
165
- post = ErrorsDemoPost.new(title: "Hi")
166
- post.valid? # true → no errors
167
- ruact_errors(post)
168
- redirect_to "/errors-demo/new"
169
- end
170
- end
171
-
172
- # Demo controller for the spec. Uses an inline `append_view_path` instead of
173
- # relying on Rails.root/app/views, so Controller#ruact_render is invoked
174
- # directly from #show rather than via default_render (which short-circuits
175
- # when the conventional template path does not exist on disk).
176
- class DemoController < ActionController::Base
177
- include Ruact::Controller
178
-
179
- append_view_path File.expand_path("../fixtures/story_7_9_views", __dir__)
180
-
181
- def show
182
- ruact_render
183
- end
184
- end
185
-
186
- # Story 10.0 — an implicit-`default_render` page controller: an EMPTY GET
187
- # action (no `render`/`respond_to`), backed by a CONVENTIONAL template at
188
- # `Rails.root/app/views/.../show.html.erb` (written via `write_view` below).
189
- # This is the exact scaffold shape (ivar-only GET) whose `*/*` request 500'd
190
- # pre-10.0. No `append_view_path` — the template must live on the default path
191
- # that `ruact_template_exists?` probes for the bug to be reachable.
192
- class ImplicitDemoController < ActionController::Base
193
- include Ruact::Controller
194
-
195
- def show; end
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
264
- end
265
-
266
- # Story 10.0 — write the implicit-render template at file-load time (before the
267
- # app's `initialize!` in `boot!`) so the `app/views` dir exists when Rails
268
- # computes its view paths.
269
- ControllerRequestSpecSupport.write_view(
270
- "controller_request_spec_support/implicit_demo", "show", <<~ERB
271
- <div>
272
- <DemoButton label={"hello"} />
273
- </div>
274
- ERB
275
- )
276
-
277
- # Reset Rails.application so this spec can boot its own minimal app even if a
278
- # prior spec ran a different Rails::Application subclass (the constant is
279
- # memoized in Rails::Application; we own the reset here).
280
- Rails.application = nil if Rails.respond_to?(:application) && !Rails.application.is_a?(Rails::Application)
281
-
282
- module Ruact # rubocop:disable Style/OneClassPerFile
283
- RSpec.describe "Story 7.9: Bug 7.8-B — render_to_string view-context ivar handoff", :story_7_9 do
284
- include Rack::Test::Methods
285
-
286
- let(:app_class) { ControllerRequestSpecSupport.app_class }
287
- let(:app) { app_class.instance }
288
-
289
- # Boot lives inside a per-example `before` hook (not `before(:all)`) so
290
- # RSpec's per-test lifecycle for rspec-mocks is active when Rails
291
- # initializers fire. Boot is memoized so the cost is paid once across all
292
- # examples.
293
- before do
294
- # railtie_spec.rb assigns Rails.logger to an instance_double via the
295
- # writer (not via rspec-mocks); the assignment persists across examples
296
- # and the request-cycle code path calls Rails.logger.* — replace with a
297
- # real logger that survives the example lifecycle.
298
- Rails.logger = Logger.new(IO::NULL)
299
- ControllerRequestSpecSupport.boot!
300
- # Story 10.0 — pin Rails.root to the booted app root. The Rails.root stub
301
- # (spec/support/rails_stub.rb) is a writable singleton ivar; doctor_spec
302
- # sets `Rails.root = <its tmpdir>` and never clears it, so in full-suite
303
- # ordering a leftover root would make `ruact_template_exists?` (which
304
- # probes `Rails.root/app/views`) miss the implicit-render template → super
305
- # → the very 500 this story closes. Re-pinning per example is order-proof.
306
- Rails.root = ControllerRequestSpecSupport.app_root
307
- # Re-prime Ruact.config after spec_helper's per-example reset wiped it.
308
- Ruact.configure do |c|
309
- c.manifest_path = ControllerRequestSpecSupport.manifest_path
310
- end
311
- end
312
-
313
- describe "happy path: PascalCase tag renders successfully" do
314
- it "GET /demo/show returns 200 with the Flight payload (was 500 pre-7.9)" do
315
- get "/demo/show"
316
- expect(last_response.status).to(eq(200),
317
- "expected 200, got #{last_response.status} body=#{last_response.body[0, 400]}")
318
- expect(last_response.body).to include("DemoButton")
319
- end
320
-
321
- it "RSC request returns text/x-component with the registered component" do
322
- get "/demo/show", {}, { "HTTP_ACCEPT" => "text/x-component" }
323
- expect(last_response.status).to eq(200)
324
- expect(last_response.headers["Content-Type"]).to include("text/x-component")
325
- expect(last_response.body).to include("DemoButton")
326
- end
327
- end
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
-
457
- describe "regression guard: render context reaches ViewHelper" do
458
- it "ViewHelper#__ruact_component__ does NOT raise the outside-flow error" do
459
- # If the handoff regresses, the request returns 500 with this exact
460
- # message in the body or raises the exception in the request chain.
461
- get "/demo/show"
462
- expect(last_response.body).not_to include("__ruact_component__ called outside a ruact_render flow")
463
- end
464
-
465
- it "Flight payload contains the registered component import row" do
466
- # End-to-end probe: a successful render emits a Flight I-row (import)
467
- # for every registered client component. The presence of the
468
- # DemoButton's module path proves the handoff ran end-to-end rather
469
- # than the request happening to 200 for some unrelated reason.
470
- get "/demo/show", {}, { "HTTP_ACCEPT" => "text/x-component" }
471
- expect(last_response.status).to eq(200)
472
- expect(last_response.body).to match(%r{\h+:I\[.*/DemoButton\.jsx}),
473
- "expected a Flight I-row referencing /DemoButton.jsx; " \
474
- "got: #{last_response.body[0, 400]}"
475
- end
476
-
477
- it "render context is cleared after the render returns" do
478
- # AC-adjacent regression: the controller's ensure block must restore
479
- # @ruact_render_context so a subsequent error/rescue render in the
480
- # same request cannot see stale context. Re-issuing the same request
481
- # on the same Rack stack exercises the per-request lifecycle.
482
- get "/demo/show"
483
- get "/demo/show"
484
- expect(last_response.status).to eq(200)
485
- end
486
-
487
- it "ensure block removes the ivar entirely when it was not defined before" do
488
- # Direct unit-level guard: when ruact_render starts on a controller
489
- # instance with no prior @ruact_render_context, the ensure block
490
- # must put the controller back into the instance_variable_defined?
491
- # = false state — not into the "ivar is defined as nil" state,
492
- # which would leak a phantom `{"ruact_render_context" => nil}`
493
- # entry into view_assigns on any subsequent error render.
494
- controller = ControllerRequestSpecSupport::DemoController.new
495
- controller.instance_variable_set(:@_request, nil)
496
- # Invoke just the ivar lifecycle by stubbing the heavy parts.
497
- allow(controller).to receive_messages(
498
- render_to_string: "<div></div>",
499
- ruact_request?: false,
500
- ruact_manifest: Ruact.manifest,
501
- controller_path: "controller_request_spec_support/demo",
502
- logger: Logger.new(IO::NULL),
503
- action_name: "show"
504
- )
505
- allow(controller).to receive(:render)
506
-
507
- expect(controller.instance_variable_defined?(:@ruact_render_context)).to be(false)
508
- controller.send(:ruact_render)
509
- expect(controller.instance_variable_defined?(:@ruact_render_context)).to be(false)
510
- end
511
- end
512
-
513
- # Story 13.3 (FR98) — the Bucket-1 (native form / navigation) half of the
514
- # Inertia-style validation `errors` round-trip (AC4): errors survive a
515
- # redirect-back in flash and arrive as an `errors` prop on the re-render.
516
- describe "Story 13.3: redirect-back errors prop (FR98)", :story_13_3 do
517
- let(:flight_headers) { { "HTTP_ACCEPT" => "text/x-component" } }
518
-
519
- it "exposes errors={} on a plain page render with no prior redirect (always present)" do
520
- get "/errors-demo/new", {}, flight_headers
521
- expect(last_response.status).to eq(200)
522
- expect(last_response.body).to include("DemoButton")
523
- # The component receives a present-but-empty errors prop, never a message.
524
- expect(last_response.body).not_to include("can't be blank")
525
- end
526
-
527
- it "survives a redirect-back and re-renders with the errors prop populated" do
528
- # Writing request (Bucket 1, Flight): registers errors, then redirects.
529
- post "/errors-demo/create", {}, flight_headers
530
- expect(last_response.headers["Content-Type"]).to include("text/x-component")
531
- expect(last_response.body).to include("redirectUrl")
532
-
533
- # The router follows the redirect with a fresh Flight GET; rack-test
534
- # carries the session cookie, so flash[:ruact_errors] is read back.
535
- get "/errors-demo/new", {}, flight_headers
536
- expect(last_response.status).to eq(200)
537
- expect(last_response.body).to include("Title can't be blank")
538
- end
539
-
540
- it "registers errors={} for a successful save and does NOT leak a message across the redirect" do
541
- post "/errors-demo/create_valid", {}, flight_headers
542
- get "/errors-demo/new", {}, flight_headers
543
- expect(last_response.status).to eq(200)
544
- expect(last_response.body).not_to include("can't be blank")
545
- end
546
-
547
- it "is single-use: a second re-render after the flash is swept shows no errors" do
548
- post "/errors-demo/create", {}, flight_headers
549
- get "/errors-demo/new", {}, flight_headers
550
- expect(last_response.body).to include("Title can't be blank")
551
-
552
- # flash is swept after the first read; the next render is clean.
553
- get "/errors-demo/new", {}, flight_headers
554
- expect(last_response.body).not_to include("Title can't be blank")
555
- end
556
- end
557
-
558
- # Story 10.0 — implicit `default_render` must degrade gracefully on non-HTML
559
- # Accept. Exercised against an ivar-only GET page action (empty body) whose
560
- # conventional template exists, so the request traverses `default_render`'s
561
- # activation predicate (NOT a hand-written `ruact_render` like demo#show).
562
- describe "Story 10.0: default_render graceful degradation on non-HTML Accept", :story_10_0 do
563
- it "GET with Accept: */* renders the HTML shell (was a 500 pre-10.0) (AC1)" do
564
- # RED pre-fix: `*/*` → format.html? false, ruact_request? false → super →
565
- # Rails renders the .html.erb outside a ruact_render flow → raises
566
- # "__ruact_component__ called outside a ruact_render flow".
567
- get "/implicit-demo/show", {}, { "HTTP_ACCEPT" => "*/*" }
568
- expect(last_response.status).to(eq(200),
569
- "expected 200, got #{last_response.status} body=#{last_response.body[0, 400]}")
570
- expect(last_response.headers["Content-Type"]).to include("text/html")
571
- expect(last_response.body).to include("DemoButton")
572
- expect(last_response.body).not_to include("__ruact_component__ called outside a ruact_render flow")
573
- end
574
-
575
- it "GET with an empty Accept header (defaults to HTML) renders the HTML shell (AC1)" do
576
- # An empty Accept token parses to `[nil]` (not blank, not a concrete
577
- # type) — it must degrade to the HTML shell, never raise NoMethodError.
578
- get "/implicit-demo/show", {}, { "HTTP_ACCEPT" => "" }
579
- expect(last_response.status).to eq(200)
580
- expect(last_response.headers["Content-Type"]).to include("text/html")
581
- expect(last_response.body).to include("DemoButton")
582
- end
583
-
584
- it "GET with Accept: text/html renders the same HTML shell as */* (AC2)" do
585
- # AC2: the explicit text/html path is unchanged and the */* path now
586
- # yields the SAME shell. Normalize the per-request CSRF token (the only
587
- # request-varying bytes) before comparing.
588
- strip_csrf = ->(body) { body.gsub(/content="[^"]+"/, 'content="CSRF"') }
589
-
590
- get "/implicit-demo/show", {}, { "HTTP_ACCEPT" => "text/html" }
591
- html_response = last_response.body
592
- expect(last_response.status).to eq(200)
593
- expect(last_response.headers["Content-Type"]).to include("text/html")
594
- expect(html_response).to include("DemoButton")
595
-
596
- get "/implicit-demo/show", {}, { "HTTP_ACCEPT" => "*/*" }
597
- expect(strip_csrf.call(last_response.body)).to eq(strip_csrf.call(html_response))
598
- end
599
-
600
- it "GET with Accept: text/x-component returns a raw Flight payload (AC3)" do
601
- get "/implicit-demo/show", {}, { "HTTP_ACCEPT" => "text/x-component" }
602
- expect(last_response.status).to eq(200)
603
- expect(last_response.headers["Content-Type"]).to include("text/x-component")
604
- expect(last_response.body).to match(%r{\h+:I\[.*/DemoButton\.jsx})
605
- end
606
-
607
- it "GET with the Ruact-Request: 1 header returns a Flight payload (AC3)" do
608
- get "/implicit-demo/show", {}, { "HTTP_RUACT_REQUEST" => "1" }
609
- expect(last_response.status).to eq(200)
610
- expect(last_response.headers["Content-Type"]).to include("text/x-component")
611
- end
612
-
613
- it "GET with concrete Accept: application/json bypasses RSC to super (AC4)" do
614
- # The concrete non-HTML format carries no html/wildcard accept, so
615
- # `default_render` must fall through to `super` (Rails default rendering).
616
- # With no JSON template and no respond_to, Rails raises an UnknownFormat /
617
- # MissingTemplate — NOT the ruact outside-flow 500. We only assert it is
618
- # NOT the ruact bug; Rails' exact choice of error is its own concern.
619
- error = nil
620
- begin
621
- get "/implicit-demo/show", {}, { "HTTP_ACCEPT" => "application/json" }
622
- rescue StandardError => e
623
- error = e
624
- end
625
- expect(error).not_to be_nil
626
- expect(error.message).not_to include("__ruact_component__ called outside a ruact_render flow")
627
- expect(error).to be_a(ActionController::ActionControllerError).or(be_a(ActionView::MissingTemplate))
628
- end
629
-
630
- it "GET with two concrete non-HTML types (application/json, application/xml) bypasses to super (AC4)" do
631
- # AC4's literal example: NEITHER member is html nor the wildcard, so the
632
- # predicate is false and the request must fall through to `super`.
633
- error = nil
634
- begin
635
- get "/implicit-demo/show", {}, { "HTTP_ACCEPT" => "application/json, application/xml" }
636
- rescue StandardError => e
637
- error = e
638
- end
639
- expect(error).not_to be_nil
640
- expect(error.message).not_to include("__ruact_component__ called outside a ruact_render flow")
641
- end
642
-
643
- it "GET with a mixed Accept that includes text/html (application/json, text/html) renders the shell" do
644
- # HTML is acceptable (listed explicitly) → membership predicate activates
645
- # the shell. default_render is only reached on an implicitly-rendered
646
- # action (no JSON representation to prefer), so serving the accepted HTML
647
- # is the sensible non-error outcome — and a 200, not the pre-10.0 500.
648
- get "/implicit-demo/show", {}, { "HTTP_ACCEPT" => "application/json, text/html" }
649
- expect(last_response.status).to eq(200)
650
- expect(last_response.headers["Content-Type"]).to include("text/html")
651
- expect(last_response.body).to include("DemoButton")
652
- end
653
-
654
- it "GET with a concrete-then-wildcard Accept (application/json, */*) renders the shell" do
655
- get "/implicit-demo/show", {}, { "HTTP_ACCEPT" => "application/json, */*" }
656
- expect(last_response.status).to eq(200)
657
- expect(last_response.headers["Content-Type"]).to include("text/html")
658
- expect(last_response.body).to include("DemoButton")
659
- end
660
-
661
- it "GET with a real browser Accept (text/html,...,*/*;q=0.8) renders the shell (AC2-adjacent)" do
662
- get "/implicit-demo/show", {}, {
663
- "HTTP_ACCEPT" => "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
664
- }
665
- expect(last_response.status).to eq(200)
666
- expect(last_response.headers["Content-Type"]).to include("text/html")
667
- expect(last_response.body).to include("DemoButton")
668
- end
669
- end
670
- end
671
- end