ruact 0.0.1 → 0.0.3

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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +86 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1680 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +89 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +330 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +176 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +188 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +113 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +248 -0
  44. data/lib/ruact/server_functions/registry.rb +148 -0
  45. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  46. data/lib/ruact/server_functions/route_source.rb +201 -0
  47. data/lib/ruact/server_functions/snapshot.rb +195 -0
  48. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  49. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  50. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  51. data/lib/ruact/server_functions.rb +75 -0
  52. data/lib/ruact/version.rb +1 -1
  53. data/lib/ruact/view_helper.rb +17 -9
  54. data/lib/ruact.rb +85 -6
  55. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  56. data/lib/tasks/benchmark.rake +15 -11
  57. data/lib/tasks/ruact.rake +81 -0
  58. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  59. data/spec/fixtures/flight/README.md +55 -7
  60. data/spec/fixtures/flight/bigint.txt +1 -0
  61. data/spec/fixtures/flight/infinity.txt +1 -0
  62. data/spec/fixtures/flight/nan.txt +1 -0
  63. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  64. data/spec/fixtures/flight/undefined.txt +1 -0
  65. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  66. data/spec/ruact/client_manifest_spec.rb +108 -0
  67. data/spec/ruact/configuration_spec.rb +501 -0
  68. data/spec/ruact/controller_request_spec.rb +204 -0
  69. data/spec/ruact/controller_spec.rb +427 -39
  70. data/spec/ruact/doctor_spec.rb +118 -0
  71. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  72. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  73. data/spec/ruact/errors_spec.rb +95 -0
  74. data/spec/ruact/flight/renderer_spec.rb +14 -3
  75. data/spec/ruact/flight/serializer_spec.rb +129 -88
  76. data/spec/ruact/html_converter_spec.rb +183 -5
  77. data/spec/ruact/install_generator_spec.rb +93 -0
  78. data/spec/ruact/query_request_spec.rb +446 -0
  79. data/spec/ruact/query_spec.rb +105 -0
  80. data/spec/ruact/railtie_spec.rb +2 -3
  81. data/spec/ruact/render_context_spec.rb +58 -0
  82. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  83. data/spec/ruact/render_pipeline_spec.rb +784 -330
  84. data/spec/ruact/serializable_spec.rb +8 -8
  85. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  86. data/spec/ruact/server_function_name_spec.rb +53 -0
  87. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  88. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  89. data/spec/ruact/server_functions/codegen_spec.rb +429 -0
  90. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  91. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  92. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  93. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  94. data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
  95. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  96. data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
  97. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  98. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  99. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  100. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  101. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  102. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  103. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  104. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  105. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  106. data/spec/ruact/server_spec.rb +180 -0
  107. data/spec/ruact/server_upload_request_spec.rb +311 -0
  108. data/spec/ruact/view_helper_spec.rb +23 -17
  109. data/spec/spec_helper.rb +52 -1
  110. data/spec/support/fixtures/pixel.png +0 -0
  111. data/spec/support/flight_wire_parser.rb +135 -0
  112. data/spec/support/flight_wire_parser_spec.rb +93 -0
  113. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  114. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  115. data/spec/support/rails_stub.rb +75 -5
  116. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
  117. data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
  118. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
  120. data/vendor/javascript/vite-plugin-ruact/index.js +3 -2
  121. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  122. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  123. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
  124. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
  125. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
  126. metadata +87 -6
  127. data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
  128. data/lib/ruact/component_registry.rb +0 -31
  129. data/lib/tasks/rsc.rake +0 -9
@@ -198,6 +198,84 @@ RSpec.describe Ruact::Doctor do
198
198
  end
199
199
  end
200
200
 
201
+ # --- check_legacy_constant ---
202
+
203
+ describe "#check_legacy_constant (Story 5.1)" do
204
+ subject(:doctor) { described_class.new }
205
+
206
+ # Built via Array#join so this spec file passes the gem-CI
207
+ # `name-propagation` guard without an exclusion (Story 5.1 review F4).
208
+ let(:legacy_const) { %w[Rails Rsc].join }
209
+ let(:legacy_gem) { %w[rails rsc].join("_") }
210
+
211
+ def make_initializer(content, filename: "ruact.rb")
212
+ dir = tmpdir.join("config", "initializers")
213
+ FileUtils.mkdir_p(dir)
214
+ File.write(dir.join(filename), content)
215
+ end
216
+
217
+ def make_app_file(content, path:)
218
+ dir = tmpdir.join("app", File.dirname(path))
219
+ FileUtils.mkdir_p(dir)
220
+ File.write(tmpdir.join("app", path), content)
221
+ end
222
+
223
+ context "when no legacy references exist" do
224
+ it "returns :pass" do
225
+ status, = doctor.send(:check_legacy_constant)
226
+ expect(status).to eq(:pass)
227
+ end
228
+ end
229
+
230
+ context "when an initializer references the legacy constant" do
231
+ before { make_initializer("#{legacy_const}.configure do |c|\n c.foo = 1\nend\n") }
232
+
233
+ it "returns :fail" do
234
+ status, = doctor.send(:check_legacy_constant)
235
+ expect(status).to eq(:fail)
236
+ end
237
+
238
+ it "message names the file:line and instructs the rename" do
239
+ _, msg = doctor.send(:check_legacy_constant)
240
+ expect(msg).to include("ruact.rb:1")
241
+ expect(msg).to include("Replace `#{legacy_const}` with `Ruact`")
242
+ end
243
+
244
+ it "message includes the rename documentation link (AC5)" do
245
+ _, msg = doctor.send(:check_legacy_constant)
246
+ expect(msg).to include("https://github.com/luizcg/ruact/blob/main/CHANGELOG.md#renamed")
247
+ end
248
+ end
249
+
250
+ context "when an app file requires the legacy gem name" do
251
+ before { make_app_file("require \"#{legacy_gem}\"\n", path: "models/foo.rb") }
252
+
253
+ it "returns :fail" do
254
+ status, = doctor.send(:check_legacy_constant)
255
+ expect(status).to eq(:fail)
256
+ end
257
+ end
258
+
259
+ context "when only the modern Ruact constant is referenced" do
260
+ before { make_initializer("Ruact.configure { |c| c.foo = 1 }\n") }
261
+
262
+ it "returns :pass" do
263
+ status, = doctor.send(:check_legacy_constant)
264
+ expect(status).to eq(:pass)
265
+ end
266
+ end
267
+
268
+ context "when a word coincidentally contains the legacy substring" do
269
+ # e.g. "TrailsRsce" should not trigger the regex (boundary check).
270
+ before { make_initializer("# documenting TrailsRsce_engine here\n") }
271
+
272
+ it "returns :pass" do
273
+ status, = doctor.send(:check_legacy_constant)
274
+ expect(status).to eq(:pass)
275
+ end
276
+ end
277
+ end
278
+
201
279
  # --- run / .run ---
202
280
 
203
281
  describe ".run / #run (AC#1, #7)" do
@@ -231,4 +309,44 @@ RSpec.describe Ruact::Doctor do
231
309
  end
232
310
  end
233
311
  end
312
+
313
+ describe "Rake task definition (Story 5.12)", :story_5_12 do
314
+ # Loads gem/lib/tasks/ruact.rake into a fresh Rake::Application so the
315
+ # task table is isolated from any other spec that may have loaded tasks.
316
+ # Asserts the new namespace is discoverable AND the legacy namespace is
317
+ # gone — guards against a partial rename (e.g. file renamed but the
318
+ # namespace inside left as :rsc, or vice-versa).
319
+ around do |example|
320
+ require "rake"
321
+ prev = Rake.application
322
+ Rake.application = Rake::Application.new
323
+ Rake.application.add_loader("rake", Rake::DefaultLoader.new)
324
+ Rake.application.add_loader("rb", Rake::DefaultLoader.new)
325
+ load File.expand_path("../../lib/tasks/ruact.rake", __dir__)
326
+ example.run
327
+ ensure
328
+ Rake.application = prev
329
+ end
330
+
331
+ let(:rake_app) { Rake.application }
332
+
333
+ it "defines Rake::Task['ruact:doctor']" do
334
+ expect(rake_app.lookup("ruact:doctor")).not_to be_nil
335
+ end
336
+
337
+ it "does NOT define the legacy Rake task under the pre-Story-5.12 namespace" do
338
+ # Build the legacy task name from fragments so this spec file stays
339
+ # grep-clean under the Story 5.12 CI guard (which rejects the literal
340
+ # `<legacy>:doctor` substring anywhere in tracked files).
341
+ legacy_task = %w[rsc doctor].join(":")
342
+ expect(rake_app.lookup(legacy_task)).to be_nil
343
+ end
344
+
345
+ it "the ruact:doctor task action invokes Ruact::Doctor.run" do
346
+ task = rake_app.lookup("ruact:doctor")
347
+ expect(task).not_to be_nil
348
+ # actions is a list of Procs; just assert at least one is attached
349
+ expect(task.actions).not_to be_empty
350
+ end
351
+ end
234
352
  end
@@ -24,13 +24,13 @@ module Ruact
24
24
  it "applies ErbPreprocessor.transform to source before calling super" do
25
25
  source = "<LikeButton postId={1} />"
26
26
  result = handler.call(fake_template, source)
27
- expect(result).to include("__rsc_component__")
27
+ expect(result).to include("__ruact_component__")
28
28
  expect(result).to include('"LikeButton"')
29
29
  expect(result).not_to include("<LikeButton")
30
30
  end
31
31
 
32
32
  it "passes source unchanged when no PascalCase tags present (fast-path)" do
33
- source = "<div class=\"hello\"><p>No RSC here</p></div>"
33
+ source = "<div class=\"hello\"><p>No ruact here</p></div>"
34
34
  result = handler.call(fake_template, source)
35
35
  expect(result).to eq(source)
36
36
  end
@@ -45,7 +45,7 @@ module Ruact
45
45
  it "transforms Suspense tags correctly" do
46
46
  source = "<Suspense fallback=\"Loading\"><PostCard /></Suspense>"
47
47
  result = handler.call(fake_template, source)
48
- expect(result).to include("__rsc_component__")
48
+ expect(result).to include("__ruact_component__")
49
49
  end
50
50
  end
51
51
  end
@@ -8,19 +8,19 @@ module Ruact
8
8
 
9
9
  describe "self-closing tags" do
10
10
  it "transforms a self-closing tag with no props" do
11
- expect(transform.call("<Button />")).to eq(%(<%= __rsc_component__("Button", {}) %>))
11
+ expect(transform.call("<Button />")).to eq(%(<%= __ruact_component__("Button", {}) %>))
12
12
  end
13
13
 
14
14
  it "transforms a self-closing tag with props" do
15
15
  result = transform.call("<LikeButton postId={@post.id} initialCount={5} />")
16
- expect(result).to eq(%(<%= __rsc_component__("LikeButton", { "postId" => @post.id, "initialCount" => 5 }) %>))
16
+ expect(result).to eq(%(<%= __ruact_component__("LikeButton", { "postId" => @post.id, "initialCount" => 5 }) %>))
17
17
  end
18
18
  end
19
19
 
20
20
  describe "opening tags" do
21
21
  it "transforms an opening tag with props" do
22
22
  result = transform.call("<Dialog open={true}>")
23
- expect(result).to eq(%(<%= __rsc_component__("Dialog", { "open" => true }) %>))
23
+ expect(result).to eq(%(<%= __ruact_component__("Dialog", { "open" => true }) %>))
24
24
  end
25
25
  end
26
26
 
@@ -39,7 +39,7 @@ module Ruact
39
39
  describe "complex prop expressions" do
40
40
  it "handles nested braces in a prop value" do
41
41
  result = transform.call("<Select options={Category.all.map { |c| c.id }} />")
42
- expect(result).to eq(%(<%= __rsc_component__("Select", { "options" => Category.all.map { |c| c.id } }) %>))
42
+ expect(result).to eq(%(<%= __ruact_component__("Select", { "options" => Category.all.map { |c| c.id } }) %>))
43
43
  end
44
44
  end
45
45
 
@@ -65,8 +65,8 @@ module Ruact
65
65
  it "transforms multiple components in the same string" do
66
66
  source = '<Button /> and <Badge label={"hello"} />'
67
67
  result = transform.call(source)
68
- expect(result).to match(/__rsc_component__\("Button"/)
69
- expect(result).to match(/__rsc_component__\("Badge"/)
68
+ expect(result).to match(/__ruact_component__\("Button"/)
69
+ expect(result).to match(/__ruact_component__\("Badge"/)
70
70
  expect(result).to match(/"hello"/)
71
71
  end
72
72
  end
@@ -82,7 +82,7 @@ module Ruact
82
82
  result = transform.call(source)
83
83
  expect(result).to match(/<div class="container">/)
84
84
  expect(result).to match(%r{<h1>Hello</h1>})
85
- expect(result).to match(/__rsc_component__\("LikeButton"/)
85
+ expect(result).to match(/__ruact_component__\("LikeButton"/)
86
86
  end
87
87
  end
88
88
  end
@@ -39,5 +39,100 @@ module Ruact
39
39
  expect { raise PreprocessorError, "test" }.to raise_error(Error)
40
40
  end
41
41
  end
42
+
43
+ describe "Ruact::ConfigurationError" do
44
+ it "is a subclass of Ruact::Error" do
45
+ expect(ConfigurationError.ancestors).to include(Error)
46
+ end
47
+
48
+ it "can be raised and rescued as Ruact::Error" do
49
+ expect { raise ConfigurationError, "test" }.to raise_error(Error)
50
+ end
51
+ end
52
+
53
+ describe "Ruact::HtmlConverterError" do
54
+ it "is a subclass of Ruact::Error" do
55
+ expect(HtmlConverterError.ancestors).to include(Error)
56
+ end
57
+
58
+ it "can be raised and rescued as Ruact::Error" do
59
+ expect { raise HtmlConverterError, "test" }.to raise_error(Error)
60
+ end
61
+ end
62
+
63
+ describe "Ruact::CurrentUserNotConfiguredError", :story_8_3 do
64
+ it "is a subclass of Ruact::Error" do
65
+ expect(CurrentUserNotConfiguredError.ancestors).to include(Error)
66
+ end
67
+
68
+ it "carries a default message that names BOTH Devise and hand-rolled-session worked examples" do
69
+ message = CurrentUserNotConfiguredError.new.message
70
+ expect(message).to include("Ruact.current_user requires Ruact.config.current_user_resolver to be set")
71
+ expect(message).to include("Devise")
72
+ expect(message).to include("env['warden']")
73
+ expect(message).to include("hand-rolled session")
74
+ expect(message).to include("rack.session")
75
+ end
76
+
77
+ it "accepts a custom message" do
78
+ expect(CurrentUserNotConfiguredError.new("explicit").message).to eq("explicit")
79
+ end
80
+ end
81
+
82
+ describe "Ruact::UploadTooLargeError", :story_8_5 do
83
+ it "is a subclass of Ruact::Error (so Story 8.4 rescue_from StandardError catches it)" do
84
+ expect(UploadTooLargeError.ancestors).to include(Error)
85
+ end
86
+
87
+ it "carries received_bytes and limit_bytes attr_readers" do
88
+ error = UploadTooLargeError.new(received_bytes: 11_534_336, limit_bytes: 10_485_760)
89
+ expect(error.received_bytes).to eq(11_534_336)
90
+ expect(error.limit_bytes).to eq(10_485_760)
91
+ end
92
+
93
+ it "synthesises a default message that includes both numbers" do
94
+ error = UploadTooLargeError.new(received_bytes: 11_534_336, limit_bytes: 10_485_760)
95
+ expect(error.message).to include("received_bytes=11534336")
96
+ expect(error.message).to include("limit_bytes=10485760")
97
+ end
98
+
99
+ it "honours an explicit custom message" do
100
+ error = UploadTooLargeError.new(received_bytes: 1, limit_bytes: 2, message: "custom")
101
+ expect(error.message).to eq("custom")
102
+ end
103
+
104
+ it "can be rescued as Ruact::Error and StandardError" do
105
+ expect { raise UploadTooLargeError.new(received_bytes: 1, limit_bytes: 0) }
106
+ .to raise_error(Error)
107
+ expect { raise UploadTooLargeError.new(received_bytes: 1, limit_bytes: 0) }
108
+ .to raise_error(StandardError)
109
+ end
110
+ end
111
+
112
+ describe "Ruact::ActionError", :story_8_3 do
113
+ it "is a subclass of Ruact::Error" do
114
+ expect(ActionError.ancestors).to include(Error)
115
+ end
116
+
117
+ it "carries status + body so the dispatcher can render without `render` access" do
118
+ error = ActionError.new(status: 422, body: { error: "invalid" })
119
+ expect(error.status).to eq(422)
120
+ expect(error.body).to eq(error: "invalid")
121
+ end
122
+
123
+ it "accepts a Symbol status" do
124
+ error = ActionError.new(status: :unprocessable_entity, body: nil)
125
+ expect(error.status).to eq(:unprocessable_entity)
126
+ expect(error.body).to be_nil
127
+ end
128
+
129
+ it "synthesises a legible message when none is given" do
130
+ expect(ActionError.new(status: 401, body: nil).message).to include("status=401")
131
+ end
132
+
133
+ it "honours an explicit custom message" do
134
+ expect(ActionError.new(status: 500, body: {}, message: "explicit").message).to eq("explicit")
135
+ end
136
+ end
42
137
  end
43
138
  end
@@ -31,7 +31,7 @@ module Ruact
31
31
  it "root row always has id 0" do
32
32
  element = ReactElement.new(type: "p")
33
33
  output = described_class.render(element, empty_manifest)
34
- expect(output.lines.last).to start_with("0:")
34
+ expect(Ruact::Spec::FlightWireParser.parse(output).last).to include(id: 0, class: :model)
35
35
  end
36
36
  end
37
37
 
@@ -61,9 +61,19 @@ module Ruact
61
61
  let(:fallback) { ReactElement.new(type: "span") }
62
62
  let(:inner) { ReactElement.new(type: "div") }
63
63
 
64
+ # Configuration is frozen post-Story 7.3, so RSpec mocks cannot proxy it.
65
+ # Configure via Ruact.configure and reset around each example.
66
+ around do |example|
67
+ Ruact.instance_variable_set(:@config, nil)
68
+ Ruact.instance_variable_set(:@configured_at_least_once, false)
69
+ example.run
70
+ Ruact.instance_variable_set(:@config, nil)
71
+ Ruact.instance_variable_set(:@configured_at_least_once, false)
72
+ end
73
+
64
74
  context "when deferred delay exceeds suspense_timeout (streaming: true)" do
65
75
  before do
66
- allow(Ruact.config).to receive(:suspense_timeout).and_return(1.0)
76
+ Ruact.configure { |c| c.suspense_timeout = 1.0 }
67
77
  end
68
78
 
69
79
  it "emits an E-type error row instead of the model row" do
@@ -76,6 +86,7 @@ module Ruact
76
86
  suspense = SuspenseElement.new(fallback: fallback, children: inner, delay: 5.0)
77
87
  rows = described_class.each(suspense, empty_manifest, streaming: true).to_a
78
88
  error_row = rows.find { |r| r.include?(":E") }
89
+ # Error message string is the contract — see Story 7.6 Decision E.
79
90
  expect(error_row).to include("Suspense timeout exceeded")
80
91
  end
81
92
 
@@ -90,7 +101,7 @@ module Ruact
90
101
 
91
102
  context "when deferred delay is within suspense_timeout (streaming: true)" do
92
103
  before do
93
- allow(Ruact.config).to receive(:suspense_timeout).and_return(10.0)
104
+ Ruact.configure { |c| c.suspense_timeout = 10.0 }
94
105
  end
95
106
 
96
107
  it "emits a model row (not an error row) and no E row" do
@@ -16,87 +16,32 @@ module Ruact
16
16
 
17
17
  let(:bundler) { StubBundlerConfig.new }
18
18
 
19
- # --- Primitives ---
20
-
21
- describe "strings" do
22
- it "serializes a plain string" do
23
- expect(render.call("hello")).to eq("0:\"hello\"\n")
24
- end
25
-
26
- it "escapes strings starting with $" do
27
- expect(render.call("$danger")).to eq("0:\"$$danger\"\n")
28
- end
29
- end
30
-
31
- describe "numbers" do
32
- it "serializes an integer" do
33
- expect(render.call(42)).to eq("0:42\n")
34
- end
35
-
36
- it "serializes a bigint (> MAX_SAFE_INTEGER) as $n<decimal>" do
37
- big = 9_007_199_254_740_992
38
- expect(render.call(big)).to eq("0:\"$n#{big}\"\n")
39
- end
40
-
41
- it "serializes Float::NAN" do
42
- expect(render.call(Float::NAN)).to eq("0:\"$NaN\"\n")
43
- end
44
-
45
- it "serializes Float::INFINITY" do
46
- expect(render.call(Float::INFINITY)).to eq("0:\"$Infinity\"\n")
47
- end
48
-
49
- it "serializes -Float::INFINITY" do
50
- expect(render.call(-Float::INFINITY)).to eq("0:\"$-Infinity\"\n")
51
- end
52
- end
53
-
54
- describe "nil/boolean" do
55
- it "serializes nil as null" do
56
- expect(render.call(nil)).to eq("0:null\n")
57
- end
58
-
59
- it "serializes true" do
60
- expect(render.call(true)).to eq("0:true\n")
61
- end
62
-
63
- it "serializes false" do
64
- expect(render.call(false)).to eq("0:false\n")
65
- end
66
- end
67
-
68
- describe "special symbols" do
69
- it "serializes :undefined as $undefined" do
70
- expect(render.call(:undefined)).to eq("0:\"$undefined\"\n")
71
- end
72
- end
73
-
74
- # --- Collections ---
75
-
76
- describe "hash" do
77
- it "serializes a hash with symbol keys" do
78
- out = render.call({ greeting: "hello", count: 42 })
79
- expect(out).to eq("0:{\"greeting\":\"hello\",\"count\":42}\n")
80
- end
81
- end
82
-
83
- describe "array" do
84
- it "serializes a plain array" do
85
- expect(render.call([1, 2, 3])).to eq("0:[1,2,3]\n")
86
- end
87
- end
88
-
89
19
  # --- React Element (DOM) ---
20
+ #
21
+ # Primitive scalar/collection inputs (nil, booleans, integer, float, string,
22
+ # `$`-escaped string, hash, array) are guarded by the fixture-mode block at
23
+ # the bottom of this file. The cases below assert on the *parsed shape* of
24
+ # ReactElement payloads — fixture mode is overkill for them since the
25
+ # invariant under test is "the parsed tuple is `["$", <type>, <key>, <props>]`",
26
+ # not the literal byte sequence.
90
27
 
91
28
  describe "ReactElement" do
92
29
  it "serializes a DOM element" do
93
30
  el = ReactElement.new(type: "div", props: { className: "box", children: "hi" })
94
- expect(render.call(el)).to eq("0:[\"$\",\"div\",null,{\"className\":\"box\",\"children\":\"hi\"}]\n")
31
+ expected = [
32
+ { id: 0, class: :model,
33
+ payload: ["$", "div", nil, { "className" => "box", "children" => "hi" }] }
34
+ ]
35
+ expect(render.call(el)).to match_flight_structure(expected)
95
36
  end
96
37
 
97
38
  it "serializes a DOM element with a key" do
98
39
  el = ReactElement.new(type: "li", key: "item-1", props: { children: "one" })
99
- expect(render.call(el)).to eq("0:[\"$\",\"li\",\"item-1\",{\"children\":\"one\"}]\n")
40
+ expected = [
41
+ { id: 0, class: :model,
42
+ payload: ["$", "li", "item-1", { "children" => "one" }] }
43
+ ]
44
+ expect(render.call(el)).to match_flight_structure(expected)
100
45
  end
101
46
  end
102
47
 
@@ -106,11 +51,18 @@ module Ruact
106
51
  it "emits an I row for the import and references $L1 in root" do
107
52
  ref = ClientReference.new(module_id: "./LikeButton", export_name: "LikeButton")
108
53
  el = ReactElement.new(type: ref, props: { postId: 1, initialCount: 5 })
109
- out = render.call(el)
110
54
 
111
- expect(out).to match(%r{1:I\["\./LikeButton","LikeButton"\]})
112
- expect(out).to match(/0:\["\$","\$L1"/)
113
- expect(out.index("1:I")).to be < out.index("0:["), "import row must precede model row"
55
+ # Story 7.6 Decision B: collapse the original sibling regex pair plus
56
+ # the `out.index("1:I") < out.index("0:[")` ordering check into a
57
+ # single structural assertion. Non-import rows compare positionally,
58
+ # so the import row in expected position 0 + the model row in
59
+ # expected position 1 encode the ordering invariant.
60
+ expected = [
61
+ { id: 1, class: :import, payload: ["./LikeButton", "LikeButton"] },
62
+ { id: 0, class: :model,
63
+ payload: ["$", "$L1", nil, { "postId" => 1, "initialCount" => 5 }] }
64
+ ]
65
+ expect(render.call(el)).to match_flight_structure(expected)
114
66
  end
115
67
 
116
68
  it "deduplicates: same ClientReference object emits only one I row" do
@@ -127,10 +79,23 @@ module Ruact
127
79
  # --- Error handling ---
128
80
 
129
81
  describe "unsupported type (AC#2)" do
82
+ # Build a class with no `as_json` method. ActiveSupport adds Object#as_json
83
+ # when Rails is loaded (Story 7.9), so Object.new no longer triggers the
84
+ # "no as_json" branch; this class explicitly undefines it.
85
+ let(:no_as_json_class) do
86
+ Class.new do
87
+ undef_method :as_json if method_defined?(:as_json) || private_method_defined?(:as_json)
88
+
89
+ def self.name
90
+ "UnsupportedType"
91
+ end
92
+ end
93
+ end
94
+
130
95
  it "raises SerializationError with class name and Serializable hint" do
131
- expect { render.call(Object.new) }
132
- .to raise_error(Ruact::SerializationError, /Object/)
133
- expect { render.call(Object.new) }
96
+ expect { render.call(no_as_json_class.new) }
97
+ .to raise_error(Ruact::SerializationError, /UnsupportedType/)
98
+ expect { render.call(no_as_json_class.new) }
134
99
  .to raise_error(Ruact::SerializationError, /include Ruact::Serializable/)
135
100
  end
136
101
  end
@@ -150,7 +115,18 @@ module Ruact
150
115
  end.new
151
116
  end
152
117
 
153
- let(:model_without_as_json) { Object.new }
118
+ let(:model_without_as_json) do
119
+ # ActiveSupport adds Object#as_json when Rails is loaded (Story 7.9), so
120
+ # Object.new no longer represents "no as_json defined". Use a class that
121
+ # explicitly undefines it instead.
122
+ Class.new do
123
+ undef_method :as_json if method_defined?(:as_json) || private_method_defined?(:as_json)
124
+
125
+ def self.name
126
+ "ModelWithoutAsJson"
127
+ end
128
+ end.new
129
+ end
154
130
 
155
131
  context "with strict_serialization: false (default)" do
156
132
  let(:render_loose) do
@@ -159,8 +135,10 @@ module Ruact
159
135
  end
160
136
 
161
137
  it "serializes via as_json, returning a hash (AC#3)" do
162
- expect(render_loose.call(model_with_as_json)).to include('"id":1')
163
- expect(render_loose.call(model_with_as_json)).to include('"name":"Alice"')
138
+ expect(render_loose.call(model_with_as_json)).to include_flight_row(
139
+ class: :model,
140
+ payload: hash_including("id" => 1, "name" => "Alice")
141
+ )
164
142
  end
165
143
 
166
144
  it "calls on_as_json_warning with class name and attribute list (AC#1, #3)" do
@@ -208,7 +186,7 @@ module Ruact
208
186
  it "serializes the array without crashing" do
209
187
  b = StubBundlerConfig.new
210
188
  output = Renderer.render(model_returning_array, b, strict_serialization: false)
211
- expect(output).to include("[1,2,3]")
189
+ expect(output).to include_flight_row(class: :model, payload: [1, 2, 3])
212
190
  end
213
191
  end
214
192
 
@@ -232,6 +210,40 @@ module Ruact
232
210
  end
233
211
  end
234
212
 
213
+ # --- Story 7.7 regression suite — as_json returning self message shape ---
214
+ #
215
+ # The "with as_json returning self" context at :169 above asserts the error
216
+ # class plus the substring "infinite recursion". That gate passes if the
217
+ # message drifts to e.g. "infinite recursion detected" with no class name
218
+ # and no actionable hint — the user reading a Sentry issue would not learn
219
+ # which model misbehaved or how to fix it. The block below gates the full
220
+ # message shape from gem/lib/ruact/flight/serializer.rb#serialize_as_json:
221
+ # the offending class name + "Ruact::Serializable" + "ruact_props" must all
222
+ # appear so the message remains debuggable in production.
223
+ describe "edge cases (Story 7.7) — as_json returning self message shape", :story_7_7 do
224
+ let(:self_returning_model) do
225
+ Class.new do
226
+ def as_json
227
+ self
228
+ end
229
+
230
+ def self.name
231
+ "SelfModel"
232
+ end
233
+ end.new
234
+ end
235
+
236
+ it "names the offending class and points the user at Ruact::Serializable + ruact_props" do
237
+ b = StubBundlerConfig.new
238
+ expect { Renderer.render(self_returning_model, b, strict_serialization: false) }
239
+ .to raise_error(Ruact::SerializationError) do |error|
240
+ expect(error.message).to include("SelfModel")
241
+ expect(error.message).to include("Ruact::Serializable")
242
+ expect(error.message).to include("ruact_props")
243
+ end
244
+ end
245
+ end
246
+
235
247
  context "with as_json raising an exception" do
236
248
  let(:model_raising_error) do
237
249
  Class.new do
@@ -281,18 +293,21 @@ module Ruact
281
293
  "FakeSerializable"
282
294
  end
283
295
 
284
- rsc_props :id, :title
296
+ ruact_props :id, :title
285
297
  end.new
286
298
  end
287
299
 
288
- it "serializes via rsc_serialize — only declared props (AC#1)" do
289
- expect(render.call(serializable_obj)).to include('"id":1', '"title":"Hello"')
300
+ it "serializes via ruact_serialize — only declared props (AC#1)" do
301
+ expect(render.call(serializable_obj)).to include_flight_row(
302
+ class: :model,
303
+ payload: hash_including("id" => 1, "title" => "Hello")
304
+ )
290
305
  end
291
306
 
292
307
  it "works with strict_serialization: true — Serializable bypasses strict gate (AC#1)" do
293
308
  b = StubBundlerConfig.new
294
309
  output = Renderer.render(serializable_obj, b, strict_serialization: true)
295
- expect(output).to include('"id":1')
310
+ expect(output).to include_flight_row(class: :model, payload: hash_including("id" => 1))
296
311
  end
297
312
 
298
313
  it "does NOT call on_as_json_warning (AC#3)" do
@@ -306,6 +321,12 @@ module Ruact
306
321
  end
307
322
 
308
323
  # --- Fixture Contracts (AC#9) ---
324
+ #
325
+ # Fixture mode is the canonical guard for the Flight wire-format invariants
326
+ # of every primitive type: byte-exact escape rules, scalar canonical bytes,
327
+ # ordering. Story 7.6 deleted the duplicate literal-`eq` blocks that lived
328
+ # at the top of this file; these fixture-mode blocks are now the single
329
+ # source of truth for those invariants.
309
330
 
310
331
  describe "fixture contracts (AC#9)" do
311
332
  let(:fixture_render) { ->(model) { Renderer.render(model, ClientManifest.from_hash({})) } }
@@ -338,6 +359,26 @@ module Ruact
338
359
  expect(fixture_render.call(3.14)).to match_flight_fixture("number_float")
339
360
  end
340
361
 
362
+ it "bigint (> MAX_SAFE_INTEGER) serializes to fixture as $n<decimal>" do
363
+ expect(fixture_render.call(9_007_199_254_740_992)).to match_flight_fixture("bigint")
364
+ end
365
+
366
+ it "Float::NAN serializes to fixture as $NaN" do
367
+ expect(fixture_render.call(Float::NAN)).to match_flight_fixture("nan")
368
+ end
369
+
370
+ it "Float::INFINITY serializes to fixture as $Infinity" do
371
+ expect(fixture_render.call(Float::INFINITY)).to match_flight_fixture("infinity")
372
+ end
373
+
374
+ it "-Float::INFINITY serializes to fixture as $-Infinity" do
375
+ expect(fixture_render.call(-Float::INFINITY)).to match_flight_fixture("negative_infinity")
376
+ end
377
+
378
+ it ":undefined serializes to fixture as $undefined" do
379
+ expect(fixture_render.call(:undefined)).to match_flight_fixture("undefined")
380
+ end
381
+
341
382
  it "mixed array serializes to fixture" do
342
383
  expect(fixture_render.call([1, "a", true, nil])).to match_flight_fixture("array")
343
384
  end
@@ -420,7 +461,7 @@ module Ruact
420
461
  "Post"
421
462
  end
422
463
 
423
- rsc_props :id, :title
464
+ ruact_props :id, :title
424
465
  end.new
425
466
  end
426
467