ruact 0.0.2 → 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 (128) 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 +164 -0
  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 +88 -5
  127. data/lib/ruact/component_registry.rb +0 -31
  128. data/lib/tasks/rsc.rake +0 -9
@@ -62,10 +62,10 @@ module Ruact
62
62
  end
63
63
 
64
64
  describe "client component via registry" do
65
- it "replaces RSC comment placeholder with the registered ReactElement" do
65
+ it "replaces Ruact comment placeholder with the registered ReactElement" do
66
66
  ref = Flight::ClientReference.new(module_id: "./LikeButton", export_name: "LikeButton")
67
- registry = [{ token: "__RSC_0__", name: "LikeButton", ref: ref, props: { "postId" => 1 } }]
68
- result = convert.call("<!-- __RSC_0__ -->", registry)
67
+ registry = [{ token: "__RUACT_0__", name: "LikeButton", ref: ref, props: { "postId" => 1 } }]
68
+ result = convert.call("<!-- __RUACT_0__ -->", registry)
69
69
 
70
70
  expect(result).to be_a(Flight::ReactElement)
71
71
  expect(result.type).to eq(ref)
@@ -74,8 +74,8 @@ module Ruact
74
74
 
75
75
  it "wraps a client component inside a parent DOM element" do
76
76
  ref = Flight::ClientReference.new(module_id: "./Button", export_name: "Button")
77
- registry = [{ token: "__RSC_0__", name: "Button", ref: ref, props: {} }]
78
- result = convert.call('<div class="wrapper"><!-- __RSC_0__ --></div>', registry)
77
+ registry = [{ token: "__RUACT_0__", name: "Button", ref: ref, props: {} }]
78
+ result = convert.call('<div class="wrapper"><!-- __RUACT_0__ --></div>', registry)
79
79
 
80
80
  expect(result.type).to eq("div")
81
81
  child = result.props["children"]
@@ -143,5 +143,183 @@ module Ruact
143
143
  })
144
144
  end
145
145
  end
146
+
147
+ describe "input validation (Story 7.4)" do
148
+ describe "nil input" do
149
+ it "raises Ruact::HtmlConverterError" do
150
+ expect { described_class.convert(nil) }.to raise_error(Ruact::HtmlConverterError)
151
+ end
152
+
153
+ it "message contains 'received nil; expected a String of HTML'" do
154
+ expect { described_class.convert(nil) }
155
+ .to raise_error(Ruact::HtmlConverterError,
156
+ /HtmlConverter\.convert received nil; expected a String of HTML/)
157
+ end
158
+
159
+ it "message contains the 'Most likely cause' hint" do
160
+ expect { described_class.convert(nil) }
161
+ .to raise_error(Ruact::HtmlConverterError, /Most likely cause:/)
162
+ expect { described_class.convert(nil) }
163
+ .to raise_error(Ruact::HtmlConverterError, /missing yield, an empty respond_to branch/)
164
+ end
165
+
166
+ it "message contains the documentation pointer" do
167
+ expect { described_class.convert(nil) }
168
+ .to raise_error(Ruact::HtmlConverterError,
169
+ /See HtmlConverter\.convert documentation for the canonical contract\./)
170
+ end
171
+
172
+ it "message points at the spec's file:line, not html_converter.rb (Called from)" do
173
+ expected_line = __LINE__ + 1
174
+ expect { described_class.convert(nil) }
175
+ .to raise_error(Ruact::HtmlConverterError,
176
+ /Called from: #{Regexp.escape(__FILE__)}:#{expected_line}/)
177
+ end
178
+
179
+ it "does not invoke Nokogiri::HTML::DocumentFragment.parse" do
180
+ allow(Nokogiri::HTML::DocumentFragment).to receive(:parse).and_call_original
181
+ expect { described_class.convert(nil) }.to raise_error(Ruact::HtmlConverterError)
182
+ expect(Nokogiri::HTML::DocumentFragment).not_to have_received(:parse)
183
+ end
184
+ end
185
+
186
+ describe "non-String inputs" do
187
+ it "raises with 'got Integer' and 'Received: 42' for an Integer" do
188
+ expect { described_class.convert(42) }
189
+ .to raise_error(Ruact::HtmlConverterError,
190
+ /HtmlConverter\.convert expected a String of HTML; got Integer/)
191
+ expect { described_class.convert(42) }
192
+ .to raise_error(Ruact::HtmlConverterError, /Received: 42/)
193
+ end
194
+
195
+ it "raises with 'got Symbol' for a Symbol" do
196
+ expect { described_class.convert(:symbol) }
197
+ .to raise_error(Ruact::HtmlConverterError, /got Symbol/)
198
+ expect { described_class.convert(:symbol) }
199
+ .to raise_error(Ruact::HtmlConverterError, /Received: :symbol/)
200
+ end
201
+
202
+ it "raises with 'got Array' for an Array" do
203
+ expect { described_class.convert(["array"]) }
204
+ .to raise_error(Ruact::HtmlConverterError, /got Array/)
205
+ expect { described_class.convert(["array"]) }
206
+ .to raise_error(Ruact::HtmlConverterError, /Received: \["array"\]/)
207
+ end
208
+
209
+ it "raises with 'got Hash' for a Hash" do
210
+ expect { described_class.convert({ hash: 1 }) }
211
+ .to raise_error(Ruact::HtmlConverterError, /got Hash/)
212
+ expect { described_class.convert({ hash: 1 }) }
213
+ .to raise_error(Ruact::HtmlConverterError, /Received: \{.*hash.*1.*\}/)
214
+ end
215
+
216
+ it "raises with 'got Object' for a generic Object" do
217
+ expect { described_class.convert(Object.new) }
218
+ .to raise_error(Ruact::HtmlConverterError, /got Object/)
219
+ end
220
+
221
+ it "message points at the spec's file:line for non-nil non-String input" do
222
+ expected_line = __LINE__ + 1
223
+ expect { described_class.convert(42) }
224
+ .to raise_error(Ruact::HtmlConverterError,
225
+ /Called from: #{Regexp.escape(__FILE__)}:#{expected_line}/)
226
+ end
227
+
228
+ it "truncates large input previews to ≤ 84 chars (80 + '...')" do
229
+ large_value = (1..10_000).to_a
230
+ message = nil
231
+ begin
232
+ described_class.convert(large_value)
233
+ rescue Ruact::HtmlConverterError => e
234
+ message = e.message
235
+ end
236
+
237
+ received_line = message.lines.find { |l| l.include?("Received:") }
238
+ expect(received_line).not_to be_nil
239
+ preview = received_line.sub(/^\s*Received:\s/, "").strip
240
+ expect(preview.length).to be <= 84
241
+ expect(preview).to end_with("...")
242
+ end
243
+ end
244
+
245
+ describe "subclasses and edge cases" do
246
+ it "accepts String subclasses (e.g. ActionView SafeBuffer-like)" do
247
+ safe_buffer_class = Class.new(String)
248
+ input = safe_buffer_class.new("<div>hi</div>")
249
+ expect { described_class.convert(input) }.not_to raise_error
250
+
251
+ result = described_class.convert(input)
252
+ expect(result).to be_a(Ruact::Flight::ReactElement)
253
+ expect(result.type).to eq("div")
254
+ end
255
+
256
+ it "accepts an empty string without raising" do
257
+ expect { described_class.convert("") }.not_to raise_error
258
+ end
259
+
260
+ it "raises Ruact::HtmlConverterError (not NoMethodError) for BasicObject inputs" do
261
+ basic = BasicObject.new
262
+ expect { described_class.convert(basic) }
263
+ .to raise_error(Ruact::HtmlConverterError, /HtmlConverter\.convert expected a String of HTML/)
264
+ end
265
+
266
+ it "produces a readable message even when value#inspect raises" do
267
+ hostile = Class.new do
268
+ def inspect
269
+ raise "I refuse to introspect"
270
+ end
271
+ end.new
272
+
273
+ expect { described_class.convert(hostile) }
274
+ .to raise_error(Ruact::HtmlConverterError, /Received: <inspect raised>/)
275
+ end
276
+
277
+ it "produces a readable message even when value#class raises (BasicObject)" do
278
+ basic = BasicObject.new
279
+ expect { described_class.convert(basic) }
280
+ .to raise_error(Ruact::HtmlConverterError) do |error|
281
+ expect(error.message).to include("got ")
282
+ expect(error.message).not_to match(/got\s*$/)
283
+ end
284
+ end
285
+ end
286
+
287
+ describe "backtrace shape" do
288
+ it "frame 0 of the backtrace points outside the gem" do
289
+ described_class.convert(nil)
290
+ rescue Ruact::HtmlConverterError => e
291
+ expect(e.backtrace.first).not_to include("/lib/ruact/html_converter.rb")
292
+ expect(e.backtrace.first).to include(__FILE__)
293
+ end
294
+
295
+ it "frame 0 references the test file when called from a spec" do
296
+ described_class.convert(42)
297
+ rescue Ruact::HtmlConverterError => e
298
+ expect(e.backtrace.first).to start_with(__FILE__)
299
+ end
300
+ end
301
+ end
302
+
303
+ # Detailed coverage of HtmlConverter input validation (nil + non-String inputs,
304
+ # backtrace shape, BasicObject handling) lives in the "input validation (Story 7.4)"
305
+ # block above (lines 147-300). This regression suite is intentionally minimal — two
306
+ # smoke specs that name the suite for greppability via `:story_7_7` and the
307
+ # "Story 7.7" describe substring; do NOT duplicate the 7.4 detailed coverage here.
308
+ describe "edge cases (Story 7.7) — code-review regression suite", :story_7_7 do
309
+ it "raises Ruact::HtmlConverterError for nil input (smoke; full coverage at :147-184)" do
310
+ expect { described_class.convert(nil) }.to raise_error(Ruact::HtmlConverterError) do |error|
311
+ expect(error.message).to include("received nil")
312
+ expect(error.message).to include("expected a String of HTML")
313
+ end
314
+ end
315
+
316
+ it "raises Ruact::HtmlConverterError for a non-String input (smoke; full coverage at :186-243)" do
317
+ expect { described_class.convert(42) }.to raise_error(Ruact::HtmlConverterError) do |error|
318
+ expect(error.message).to include("expected a String of HTML")
319
+ expect(error.message).to include("got Integer")
320
+ expect(error.message).to include("Received: 42")
321
+ end
322
+ end
323
+ end
146
324
  end
147
325
  end
@@ -208,5 +208,98 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
208
208
  expect(content).not_to include('from "vite-plugin-ruact"')
209
209
  end
210
210
  end
211
+
212
+ describe "server-functions scaffold (Story 8.0a — AC8)", :story_8_0a do
213
+ # Reproduces append_gitignore_entries logic from the install generator
214
+ # so the spec can be expressed without a full Rails::Generators::TestCase.
215
+ let(:gitignore_entries) do
216
+ [
217
+ "app/javascript/.ruact/server-functions.ts",
218
+ # Story 9.3 — route-driven (v2) parallel inspection target.
219
+ "app/javascript/.ruact/server-functions.next.ts",
220
+ "tmp/cache/ruact/"
221
+ ]
222
+ end
223
+
224
+ # Mirror of the real generator's append_gitignore_entries logic
225
+ # (gem/lib/generators/ruact/install/install_generator.rb). Kept in sync
226
+ # at line-set membership granularity — the previous substring-based
227
+ # version drifted from the real generator after the Chunk 1 review's
228
+ # line-set fix and the Re-run 2026-05-14 patch brings this helper
229
+ # back into parity.
230
+ def append_gitignore_entries(dest_root)
231
+ gitignore = File.join(dest_root, ".gitignore")
232
+ return :no_gitignore unless File.exist?(gitignore)
233
+
234
+ existing_lines = File.read(gitignore).each_line.to_set { |line| line.chomp.strip }
235
+ new_entries = gitignore_entries.reject { |e| existing_lines.include?(e) }
236
+ return :already_present if new_entries.empty?
237
+
238
+ File.open(gitignore, "a") do |io|
239
+ io.puts
240
+ io.puts "# ruact (Story 8.0a — auto-generated server-functions module)"
241
+ new_entries.each { |entry| io.puts entry }
242
+ end
243
+ :appended
244
+ end
245
+
246
+ def create_gitkeep(dest_root)
247
+ keep = File.join(dest_root, "app/javascript/.ruact/.gitkeep")
248
+ FileUtils.mkdir_p(File.dirname(keep))
249
+ return :already_present if File.exist?(keep)
250
+
251
+ File.write(keep, "")
252
+ :created
253
+ end
254
+
255
+ it "creates .ruact/.gitkeep so the directory is checkable in (Story 8.0a)" do
256
+ result = create_gitkeep(tmpdir)
257
+ expect(result).to eq(:created)
258
+ expect(File).to exist(File.join(tmpdir, "app/javascript/.ruact/.gitkeep"))
259
+ end
260
+
261
+ it "appends both .gitignore entries when missing (Story 8.0a)", :aggregate_failures do
262
+ write_file(".gitignore", "/tmp\n")
263
+ append_gitignore_entries(tmpdir)
264
+ content = read_file(".gitignore")
265
+ expect(content).to include("app/javascript/.ruact/server-functions.ts")
266
+ expect(content).to include("app/javascript/.ruact/server-functions.next.ts")
267
+ expect(content).to include("tmp/cache/ruact/")
268
+ end
269
+
270
+ it "is idempotent — running twice does not duplicate entries (Story 8.0a — pitfall #5)" do
271
+ write_file(".gitignore", "/tmp\n")
272
+ append_gitignore_entries(tmpdir)
273
+ append_gitignore_entries(tmpdir)
274
+
275
+ content = read_file(".gitignore")
276
+ expect(content.scan("app/javascript/.ruact/server-functions.ts").size).to eq(1)
277
+ expect(content.scan("tmp/cache/ruact/").size).to eq(1)
278
+ end
279
+
280
+ it "does not write to .gitignore when both entries already exist" do
281
+ write_file(".gitignore", "/tmp\napp/javascript/.ruact/server-functions.ts\n" \
282
+ "app/javascript/.ruact/server-functions.next.ts\ntmp/cache/ruact/\n")
283
+ result = append_gitignore_entries(tmpdir)
284
+ expect(result).to eq(:already_present)
285
+ end
286
+
287
+ it "still appends 'tmp/cache/ruact/' when only a longer prefix-matching path is present " \
288
+ "(Re-run 2026-05-14 — line-set semantics, not substring)", :aggregate_failures do
289
+ # The Chunk 1 review's line-set fix makes the real generator append
290
+ # `tmp/cache/ruact/` even when the file already contains a deeper
291
+ # path like `tmp/cache/ruact/some-cache.bin`. The helper used to
292
+ # use substring matching, which would skip the entry — a silent
293
+ # drift between helper and generator. This test pins the helper
294
+ # to the same semantics.
295
+ write_file(".gitignore", "/tmp\ntmp/cache/ruact/some-cache.bin\n")
296
+ append_gitignore_entries(tmpdir)
297
+ content = read_file(".gitignore")
298
+ # Exact-line match: a new "tmp/cache/ruact/" line is appended even
299
+ # though the file already contains "tmp/cache/ruact/some-cache.bin"
300
+ expect(content.each_line.to_a).to include("tmp/cache/ruact/\n")
301
+ expect(content).to include("app/javascript/.ruact/server-functions.ts")
302
+ end
303
+ end
211
304
  end
212
305
  end