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,928 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "spec_helper"
4
-
5
- module Ruact
6
- RSpec.describe RenderPipeline do
7
- let(:manifest) do
8
- ClientManifest.from_hash({
9
- "LikeButton" => {
10
- "id" => "/assets/LikeButton-abc.js",
11
- "name" => "LikeButton",
12
- "chunks" => ["/assets/LikeButton-abc.js"]
13
- }
14
- })
15
- end
16
-
17
- let(:manifest_with_post_card) do
18
- ClientManifest.from_hash({
19
- "LikeButton" => {
20
- "id" => "/assets/LikeButton-abc.js",
21
- "name" => "LikeButton",
22
- "chunks" => ["/assets/LikeButton-abc.js"]
23
- },
24
- "PostCard" => {
25
- "id" => "/assets/PostCard-abc.js",
26
- "name" => "PostCard",
27
- "chunks" => ["/assets/PostCard-abc.js"]
28
- }
29
- })
30
- end
31
-
32
- let(:pipeline) { described_class.new(manifest) }
33
-
34
- # Methods (not `let`) so nested describes don't exceed RSpec/MultipleMemoizedHelpers
35
- # while still sharing a single source of truth for the LikeButton import row shape.
36
- def like_button_chunk
37
- "/assets/LikeButton-abc.js"
38
- end
39
-
40
- def like_button_import
41
- { id: 1, class: :import, payload: [like_button_chunk, "LikeButton", [like_button_chunk]] }
42
- end
43
-
44
- # Helper for the ERB-input path of #render. All describe blocks below — except
45
- # the explicitly-marked "#render with html input" — exercise this path.
46
- def render_erb(erb_source, **locals)
47
- ctx = Object.new
48
- locals.each { |k, v| ctx.instance_variable_set("@#{k}", v) }
49
- pipeline.render({ erb: erb_source, binding: ctx.instance_eval { binding } }, mode: :string)
50
- end
51
-
52
- describe "#render with erb input" do
53
- describe "plain HTML" do
54
- it "serializes a plain HTML element" do
55
- output = render_erb('<div class="hello"><p>World</p></div>')
56
- expect(output).to match_flight_structure([
57
- { id: 0, class: :model,
58
- payload: ["$", "div", nil, {
59
- "className" => "hello",
60
- "children" => ["$", "p", nil, { "children" => "World" }]
61
- }] }
62
- ])
63
- end
64
- end
65
-
66
- describe "unknown component" do
67
- it "raises an error when component is not in manifest" do
68
- expect { render_erb("<Button />") }.to raise_error(an_object_satisfying { |e|
69
- e.message.include?("not found in manifest")
70
- })
71
- end
72
- end
73
-
74
- describe "client component with props" do
75
- it "emits I row, serializes props, and puts root at ID 0" do
76
- output = render_erb("<div><LikeButton postId={@post_id} initialCount={5} /></div>",
77
- post_id: 42)
78
-
79
- # Story 7.6 Decision B: collapse the four sibling regex assertions plus
80
- # the `output.lines.last.start_with?("0:")` ordering check into a single
81
- # structural assertion. Non-import rows compare positionally, so the
82
- # import in expected position 0 + the model in position 1 encodes
83
- # "import row precedes (and root is last)".
84
- inner_button = ["$", "$L1", nil, { "postId" => 42, "initialCount" => 5 }]
85
- expected = [
86
- like_button_import,
87
- { id: 0, class: :model, payload: ["$", "div", nil, { "children" => inner_button }] }
88
- ]
89
- expect(output).to match_flight_structure(expected)
90
- end
91
- end
92
-
93
- describe "import row ordering" do
94
- it "emits the I row before the root model row" do
95
- output = render_erb("<LikeButton postId={1} />")
96
- lines = output.lines.map(&:strip).reject(&:empty?)
97
-
98
- import_idx = lines.index { |l| l.include?(":I[") }
99
- model_idx = lines.index { |l| l.start_with?("0:") }
100
-
101
- expect(import_idx).to be < model_idx, "I row must come before the root model row"
102
- end
103
- end
104
-
105
- describe "ERB instance variables" do
106
- it "evaluates ERB and passes instance variables into the output" do
107
- output = render_erb("<p><%= @title %></p>", title: "Hello RSC")
108
- expect(output).to match_flight_structure([
109
- { id: 0, class: :model,
110
- payload: ["$", "p", nil, { "children" => "Hello RSC" }] }
111
- ])
112
- end
113
- end
114
-
115
- describe "thread safety (NFR8)" do
116
- it "isolates component state across 10 concurrent renders" do
117
- results = Array.new(10)
118
- mutex = Mutex.new
119
- errors = []
120
-
121
- threads = Array.new(10) do |i|
122
- Thread.new do
123
- ctx = Object.new
124
- ctx.instance_variable_set(:@index, i)
125
- output = pipeline.render(
126
- { erb: "<LikeButton postId={@index} />", binding: ctx.instance_eval { binding } },
127
- mode: :string
128
- )
129
- mutex.synchronize { results[i] = output }
130
- rescue StandardError => e
131
- mutex.synchronize { errors << e }
132
- end
133
- end
134
-
135
- threads.each(&:join)
136
-
137
- expect(errors).to be_empty, "Threads raised: #{errors.map(&:message).join(', ')}"
138
-
139
- 10.times do |i|
140
- expect(results[i]).to include_flight_row(
141
- class: :model, payload: array_including(hash_including("postId" => i))
142
- ), "Thread #{i} must contain postId=#{i} — got: #{results[i].inspect}"
143
- end
144
- end
145
- end
146
-
147
- describe "determinism (NFR16)" do
148
- it "produces identical byte output on repeated renders of the same view" do
149
- first = render_erb("<div><LikeButton postId={1} /></div>")
150
- second = render_erb("<div><LikeButton postId={1} /></div>")
151
- # Determinism check — byte equality is the contract, see Story 7.6 Decision E.
152
- expect(first).to eq(second)
153
- end
154
-
155
- it "produces different output for different input data" do
156
- output_a = render_erb("<LikeButton postId={1} />")
157
- output_b = render_erb("<LikeButton postId={2} />")
158
- # Determinism check — byte inequality is the contract, see Story 7.6 Decision E.
159
- expect(output_a).not_to eq(output_b)
160
- end
161
- end
162
-
163
- # --- Dual-path resolution specs (Story 1.5) ---
164
-
165
- describe "dual-path resolution via controller_path" do
166
- let(:dual_manifest) do
167
- ClientManifest.from_hash({
168
- "LikeButton" => {
169
- "id" => "/LikeButton.jsx",
170
- "name" => "LikeButton",
171
- "chunks" => ["/LikeButton.jsx"]
172
- },
173
- "posts/_like_button" => {
174
- "id" => "/posts/_like_button.jsx",
175
- "name" => "default",
176
- "chunks" => ["/posts/_like_button.jsx"]
177
- }
178
- })
179
- end
180
-
181
- it "uses co-located component when controller_path matches (AC#2, AC#3)" do
182
- pipeline = described_class.new(dual_manifest, controller_path: "posts")
183
- ctx = Object.new
184
- output = pipeline.render({ erb: "<LikeButton />", binding: ctx.instance_eval { binding } }, mode: :string)
185
- expect(output).to include_flight_row(class: :import, payload: array_including("/posts/_like_button.jsx"))
186
- expect(output).not_to include_flight_row(class: :import, payload: array_including("/LikeButton.jsx"))
187
- end
188
-
189
- it "uses shared component when no controller_path given (AC#1)" do
190
- pipeline = described_class.new(dual_manifest)
191
- ctx = Object.new
192
- output = pipeline.render({ erb: "<LikeButton />", binding: ctx.instance_eval { binding } }, mode: :string)
193
- expect(output).to include_flight_row(class: :import, payload: array_including("/LikeButton.jsx"))
194
- expect(output).not_to include_flight_row(class: :import, payload: array_including("/posts/_like_button.jsx"))
195
- end
196
-
197
- it "falls back to shared when controller_path has no co-located key (AC#4)" do
198
- pipeline = described_class.new(dual_manifest, controller_path: "comments")
199
- ctx = Object.new
200
- output = pipeline.render({ erb: "<LikeButton />", binding: ctx.instance_eval { binding } }, mode: :string)
201
- expect(output).to include_flight_row(class: :import, payload: array_including("/LikeButton.jsx"))
202
- end
203
- end
204
-
205
- # --- Prop type integration specs (AC#1–#7) ---
206
-
207
- describe "prop types via ERB (AC#1–#7)" do
208
- it "integer prop is a JSON number, not a string (AC#1)" do
209
- output = render_erb("<LikeButton postId={@count} />", count: 42)
210
- expect(output).to include_flight_row(class: :model,
211
- payload: array_including(hash_including("postId" => 42)))
212
- expect(output).not_to include_flight_row(class: :model,
213
- payload: array_including(hash_including("postId" => "42")))
214
- end
215
-
216
- it "string prop is a JSON string (AC#2)" do
217
- output = render_erb("<LikeButton label={@title} />", title: "hello")
218
- expect(output).to include_flight_row(class: :model,
219
- payload: array_including(hash_including("label" => "hello")))
220
- end
221
-
222
- it "dollar-prefixed string is escaped with one extra $ (AC#3)" do
223
- # The `$`-prefix escape is a Flight-protocol wire convention, not a
224
- # JSON escape — the parser is byte-faithful and does NOT decode the
225
- # extra `$` away. So the parsed prop value carries the escaped form
226
- # `"$$9.99"`. The byte-exactness of the escape rule itself is
227
- # guarded by serializer_spec's `string_dollar_escape` fixture; here
228
- # we just assert that the escape kicks in through the ERB pipeline.
229
- output = render_erb("<LikeButton label={@price} />", price: "$9.99")
230
- expect(output).to include_flight_row(class: :model,
231
- payload: array_including(hash_including("label" => "$$9.99")))
232
- end
233
-
234
- it "boolean true prop is a JSON boolean literal (AC#4)" do
235
- output = render_erb("<LikeButton enabled={true} />")
236
- expect(output).to include_flight_row(class: :model,
237
- payload: array_including(hash_including("enabled" => true)))
238
- expect(output).not_to include_flight_row(class: :model,
239
- payload: array_including(hash_including("enabled" => "true")))
240
- end
241
-
242
- it "boolean false prop is a JSON boolean literal (AC#4)" do
243
- output = render_erb("<LikeButton active={false} />")
244
- expect(output).to include_flight_row(class: :model,
245
- payload: array_including(hash_including("active" => false)))
246
- expect(output).not_to include_flight_row(class: :model,
247
- payload: array_including(hash_including("active" => "false")))
248
- end
249
-
250
- it "nil prop becomes JSON null (AC#5)" do
251
- output = render_erb("<LikeButton value={nil} />")
252
- expect(output).to include_flight_row(class: :model,
253
- payload: array_including(hash_including("value" => nil)))
254
- expect(output).not_to include_flight_row(class: :model,
255
- payload: array_including(hash_including("value" => "nil")))
256
- end
257
-
258
- it "array prop has correctly typed elements (AC#6)" do
259
- output = render_erb("<LikeButton items={[1, \"a\", true, nil]} />")
260
- expect(output).to include_flight_row(
261
- class: :model,
262
- payload: array_including(hash_including("items" => [1, "a", true, nil]))
263
- )
264
- end
265
-
266
- it "hash prop produces a JSON object with correct types (AC#7)" do
267
- output = render_erb("<LikeButton opts={{debug: true, count: 5, label: \"x\"}} />")
268
- expect(output).to include_flight_row(
269
- class: :model,
270
- payload: array_including(hash_including("opts" => { "debug" => true, "count" => 5, "label" => "x" }))
271
- )
272
- end
273
- end
274
-
275
- # --- Loop / local variables spec (AC#8) ---
276
-
277
- describe "loop — local variables as props (AC#8)" do
278
- let(:post_struct) { Struct.new(:title, :id) }
279
- let(:posts) do
280
- [post_struct.new("First", 1), post_struct.new("Second", 2), post_struct.new("Third", 3)]
281
- end
282
-
283
- let(:loop_output) do
284
- pipeline_with_cards = described_class.new(manifest_with_post_card)
285
- ctx = Object.new
286
- ctx.instance_variable_set(:@posts, posts)
287
- pipeline_with_cards.render(
288
- {
289
- erb: "<% @posts.each do |post| %><PostCard title={post.title} id={post.id} /><% end %>",
290
- binding: ctx.instance_eval { binding }
291
- },
292
- mode: :string
293
- )
294
- end
295
-
296
- # Loop renders multiple PostCards as siblings under a Fragment-like root,
297
- # so model row 0's payload is an Array of React-element tuples
298
- # ([["$", "$L1", nil, {props}], …]). Predicate is nested:
299
- # array_including(array_including(hash_including(prop))).
300
- it "each PostCard receives its correct title prop" do
301
- %w[First Second Third].each do |title|
302
- expect(loop_output).to include_flight_row(
303
- class: :model,
304
- payload: array_including(array_including(hash_including("title" => title)))
305
- )
306
- end
307
- end
308
-
309
- it "each PostCard receives its correct id prop as a JSON number" do
310
- [1, 2, 3].each do |id|
311
- expect(loop_output).to include_flight_row(
312
- class: :model,
313
- payload: array_including(array_including(hash_including("id" => id)))
314
- )
315
- end
316
- end
317
- end
318
-
319
- # --- Story 2.2: as_json integration ---
320
-
321
- describe "as_json integration (Story 2.2)" do
322
- let(:as_json_manifest) do
323
- ClientManifest.from_hash({
324
- "PostCard" => {
325
- "id" => "/assets/PostCard-abc.js",
326
- "name" => "PostCard",
327
- "chunks" => ["/assets/PostCard-abc.js"]
328
- }
329
- })
330
- end
331
- let(:fake_logger) { instance_double(::Logger, warn: nil) }
332
- let(:pipeline) { described_class.new(as_json_manifest, logger: fake_logger) }
333
-
334
- let(:post_like_object) do
335
- Class.new do
336
- def as_json
337
- { "id" => 1, "title" => "Hello" }
338
- end
339
-
340
- def self.name
341
- "Post"
342
- end
343
- end.new
344
- end
345
-
346
- it "serializes as_json object as a JSON object prop (AC#1)" do
347
- output = render_erb("<PostCard post={@the_post} />", the_post: post_like_object)
348
- expect(output).to include_flight_row(
349
- class: :model,
350
- payload: array_including(hash_including("post" => hash_including("id" => 1, "title" => "Hello")))
351
- )
352
- end
353
-
354
- it "emits [ruact] warning via the injected logger (AC#1, #3)" do
355
- render_erb("<PostCard post={@the_post} />", the_post: post_like_object)
356
- expect(fake_logger).to have_received(:warn)
357
- .with(match(/\[ruact\] WARNING: Post serialized via as_json/))
358
- end
359
-
360
- it "warning includes attribute names (AC#1)" do
361
- render_erb("<PostCard post={@the_post} />", the_post: post_like_object)
362
- expect(fake_logger).to have_received(:warn)
363
- .with(match(/ALL attributes exposed to client: id, title/))
364
- end
365
-
366
- context "with strict_serialization pipeline" do
367
- let(:pipeline) { described_class.new(as_json_manifest, logger: fake_logger) }
368
-
369
- # Configuration is frozen post-Story 7.3, so RSpec mocks cannot proxy it.
370
- # Configure via Ruact.configure and reset around the example.
371
- around do |example|
372
- Ruact.instance_variable_set(:@config, nil)
373
- Ruact.instance_variable_set(:@configured_at_least_once, false)
374
- example.run
375
- Ruact.instance_variable_set(:@config, nil)
376
- Ruact.instance_variable_set(:@configured_at_least_once, false)
377
- end
378
-
379
- it "raises SerializationError when strict_serialization: true (AC#2)" do
380
- Ruact.configure { |c| c.strict_serialization = true }
381
- expect { render_erb("<PostCard post={@the_post} />", the_post: post_like_object) }
382
- .to raise_error(Ruact::SerializationError, /strict_serialization/)
383
- end
384
- end
385
- end
386
-
387
- # --- Story 2.3: Serializable integration ---
388
-
389
- describe "Serializable integration (Story 2.3)" do
390
- let(:serializable_manifest) do
391
- ClientManifest.from_hash({
392
- "PostCard" => {
393
- "id" => "/assets/PostCard-abc.js",
394
- "name" => "PostCard",
395
- "chunks" => ["/assets/PostCard-abc.js"]
396
- }
397
- })
398
- end
399
- let(:fake_logger_s) { instance_double(::Logger, warn: nil) }
400
- let(:pipeline) { described_class.new(serializable_manifest, logger: fake_logger_s) }
401
-
402
- let(:serializable_post) do
403
- Class.new do
404
- include Ruact::Serializable
405
-
406
- attr_reader :id, :title
407
-
408
- def initialize
409
- @id = 1
410
- @title = "Hello"
411
- end
412
-
413
- def self.name
414
- "Post"
415
- end
416
-
417
- ruact_props :id, :title
418
- end.new
419
- end
420
-
421
- it "serializes only declared props (AC#1)" do
422
- output = render_erb("<PostCard post={@the_post} />", the_post: serializable_post)
423
- expect(output).to include_flight_row(
424
- class: :model,
425
- payload: array_including(hash_including("post" => hash_including("id" => 1, "title" => "Hello")))
426
- )
427
- end
428
-
429
- it "does NOT emit [ruact] warning (AC#3)" do
430
- render_erb("<PostCard post={@the_post} />", the_post: serializable_post)
431
- expect(fake_logger_s).not_to have_received(:warn)
432
- end
433
- end
434
-
435
- # --- Story 2.1: useState/useEffect/event handler prop types ---
436
-
437
- describe "client components with hook prop types (Story 2.1)" do
438
- let(:manifest_with_hooks) do
439
- ClientManifest.from_hash({
440
- "CounterButton" => {
441
- "id" => "/assets/CounterButton-abc.js",
442
- "name" => "CounterButton",
443
- "chunks" => ["/assets/CounterButton-abc.js"]
444
- },
445
- "SearchInput" => {
446
- "id" => "/assets/SearchInput-abc.js",
447
- "name" => "SearchInput",
448
- "chunks" => ["/assets/SearchInput-abc.js"]
449
- }
450
- })
451
- end
452
- let(:pipeline) { described_class.new(manifest_with_hooks) }
453
-
454
- it "serializes string prop (AC#3 — useState initial string, onChange placeholder)" do
455
- output = render_erb('<SearchInput placeholder={"Search..."} />')
456
- expect(output).to include_flight_row(
457
- class: :model, payload: array_including(hash_including("placeholder" => "Search..."))
458
- )
459
- end
460
-
461
- it "serializes boolean true prop for useState initial value (AC#1)" do
462
- output = render_erb("<CounterButton enabled={true} />")
463
- expect(output).to include_flight_row(
464
- class: :model, payload: array_including(hash_including("enabled" => true))
465
- )
466
- end
467
-
468
- it "serializes boolean false prop for disabled state (AC#1)" do
469
- output = render_erb("<CounterButton disabled={false} />")
470
- expect(output).to include_flight_row(
471
- class: :model, payload: array_including(hash_including("disabled" => false))
472
- )
473
- end
474
-
475
- it "serializes nil prop as JSON null — no hydration mismatch for absent optionals (AC#4)" do
476
- output = render_erb("<CounterButton initialCount={nil} />")
477
- expect(output).to include_flight_row(
478
- class: :model, payload: array_including(hash_including("initialCount" => nil))
479
- )
480
- end
481
-
482
- it "serializes mixed props (integer + string + boolean) in a single component (AC#1, #3, #4)" do
483
- # Three sibling assertions on the same render → Decision B (full structure)
484
- # so the failure message names exactly which prop drifted, not just
485
- # "some row matched, some didn't".
486
- output = render_erb('<CounterButton initialCount={0} label={"Votes"} disabled={false} />')
487
- counter_module = "/assets/CounterButton-abc.js"
488
- expected = [
489
- { id: 1, class: :import, payload: [counter_module, "CounterButton", [counter_module]] },
490
- { id: 0, class: :model,
491
- payload: ["$", "$L1", nil, { "initialCount" => 0, "label" => "Votes", "disabled" => false }] }
492
- ]
493
- expect(output).to match_flight_structure(expected)
494
- end
495
- end
496
-
497
- describe "nested pipeline.render sharing a binding receiver" do
498
- let(:nesting_pipeline) { described_class.new(manifest) }
499
-
500
- it "restores the outer render context after an inner render completes (Story 7.1 review F1)" do
501
- # Reproduces the original review concern: an outer pipeline.render whose
502
- # ERB triggers an inner pipeline.render on the same binding receiver
503
- # would, under the old closure-based inject_helper, overwrite the
504
- # singleton method's bound context. After the inner render returned,
505
- # the outer ERB's __ruact_component__ calls would register into the
506
- # inner's discarded RenderContext — leaking the outer component out.
507
- #
508
- # The fix stores the active context on the receiver as an ivar and
509
- # save/restores it around ERB evaluation.
510
- inner = nesting_pipeline
511
- receiver = Object.new
512
- receiver.define_singleton_method(:run_inner) do
513
- inner.render({ erb: "<LikeButton postId={999} />", binding: binding }, mode: :string)
514
- ""
515
- end
516
-
517
- outer = nesting_pipeline.render(
518
- { erb: "<div><%= run_inner %><LikeButton postId={1} /></div>", binding: receiver.instance_eval do
519
- binding
520
- end },
521
- mode: :string
522
- )
523
-
524
- # The outer LikeButton (postId=1) is nested inside the div's children,
525
- # so the postId hash lives at payload[3]["children"][3]. Asserting on
526
- # the full known structure proves the inner render's postId=999 did
527
- # not leak through the registry into the outer's children.
528
- expect(outer).to match_flight_structure([
529
- like_button_import,
530
- { id: 0, class: :model,
531
- payload: ["$", "div", nil, {
532
- "children" => ["$", "$L1", nil, { "postId" => 1 }]
533
- }] }
534
- ])
535
- end
536
- end
537
-
538
- # --- Story 7.7 regression suite — render context re-entry (post-7.1 shape) ---
539
- #
540
- # The happy-path round-trip (outer→inner→outer; outer's component registers
541
- # in its own context) is gated by ":497 nested pipeline.render sharing a
542
- # binding receiver" above. This block adds the three gaps the 2026-04-30
543
- # code review identified: inner-raises ensure restoration, 3-level nesting,
544
- # and sequential receiver reuse.
545
- describe "edge cases (Story 7.7) — render context re-entry", :story_7_7 do
546
- let(:nesting_pipeline) { described_class.new(manifest) }
547
-
548
- it "restores the outer's render context inside the inner ensure before re-raise" do
549
- # Three contracts under test:
550
- # 1. Outer's ERB sees the outer's RenderContext on the receiver — captured
551
- # via `capture_outer` before run_inner triggers the failure path. This
552
- # pins the *specific* outer context object so contract 2 can assert
553
- # object identity (not just "some RenderContext").
554
- # 2. Inner's ensure (render_pipeline.rb#render_erb_enum :174-178) runs
555
- # BEFORE the inner's `raise` propagates, so by the time a rescue
556
- # handler sits on top of `inner.render(...)`, the ivar holds the
557
- # *same* outer RenderContext captured in contract 1 — proving the
558
- # inner restored to the outer's specific context, not just any
559
- # RenderContext, and not nil. Capturing inside the rescue observes
560
- # the restoration before the outer's ensure later masks it as nil.
561
- # 3. Outer's ensure runs after the re-raised manifest failure unwinds
562
- # through outer ERB; post-render the ivar is back to its pre-render
563
- # value (nil for a fresh receiver).
564
- inner = nesting_pipeline
565
- captured_outer_ctx = nil
566
- captured_during_unwind = nil
567
- receiver = Object.new
568
- receiver.define_singleton_method(:capture_outer) do
569
- captured_outer_ctx = instance_variable_get(:@__ruact_render_context__)
570
- ""
571
- end
572
- receiver.define_singleton_method(:run_inner) do
573
- inner.render({ erb: "<UnknownComponent />", binding: binding }, mode: :string)
574
- rescue Ruact::ManifestError
575
- captured_during_unwind = instance_variable_get(:@__ruact_render_context__)
576
- raise
577
- end
578
-
579
- outer_input = {
580
- erb: "<div><%= capture_outer %><%= run_inner %></div>",
581
- binding: receiver.instance_eval { binding }
582
- }
583
- expect { nesting_pipeline.render(outer_input, mode: :string) }
584
- .to raise_error(Ruact::ManifestError) do |error|
585
- expect(error.message).to include("not found in manifest")
586
- end
587
-
588
- # Contract 1 — the outer's RenderContext was on the receiver during outer ERB eval.
589
- expect(captured_outer_ctx).to be_a(Ruact::RenderContext)
590
- # Contract 2 — the inner ensure restored the SAME outer RenderContext (object identity),
591
- # not merely some other RenderContext instance.
592
- expect(captured_during_unwind).to equal(captured_outer_ctx)
593
- # Contract 3 — outer ensure restored prev_ctx (nil for a fresh receiver).
594
- expect(receiver.instance_variable_get(:@__ruact_render_context__)).to be_nil
595
- end
596
-
597
- it "isolates render contexts across 3 levels of nested renders on the same receiver" do
598
- pipe = nesting_pipeline
599
- receiver = Object.new
600
- receiver.define_singleton_method(:run_inner) do
601
- pipe.render({ erb: "<LikeButton postId={3} />", binding: binding }, mode: :string)
602
- ""
603
- end
604
- receiver.define_singleton_method(:run_middle) do
605
- pipe.render(
606
- { erb: "<LikeButton postId={2} /><%= run_inner %>", binding: binding },
607
- mode: :string
608
- )
609
- ""
610
- end
611
-
612
- outer = nesting_pipeline.render(
613
- { erb: "<div><%= run_middle %><LikeButton postId={1} /></div>",
614
- binding: receiver.instance_eval { binding } },
615
- mode: :string
616
- )
617
-
618
- # Outer's wire output sees only postId=1 — middle's postId=2 and inner's
619
- # postId=3 stayed in their own RenderContexts. The middle/inner output
620
- # was discarded by the "" return from each singleton method.
621
- expect(outer).to match_flight_structure([
622
- like_button_import,
623
- { id: 0, class: :model,
624
- payload: ["$", "div", nil, {
625
- "children" => ["$", "$L1", nil, { "postId" => 1 }]
626
- }] }
627
- ])
628
- end
629
-
630
- it "isolates components across sequential renders that share a binding receiver" do
631
- receiver = Object.new
632
- binding_for_receiver = receiver.instance_eval { binding }
633
-
634
- output_a = nesting_pipeline.render(
635
- { erb: "<LikeButton postId={11} />", binding: binding_for_receiver },
636
- mode: :string
637
- )
638
- output_b = nesting_pipeline.render(
639
- { erb: "<LikeButton postId={22} />", binding: binding_for_receiver },
640
- mode: :string
641
- )
642
-
643
- expect(output_a).to include_flight_row(class: :model,
644
- payload: array_including(hash_including("postId" => 11)))
645
- expect(output_a).not_to include_flight_row(class: :model,
646
- payload: array_including(hash_including("postId" => 22)))
647
- expect(output_b).to include_flight_row(class: :model,
648
- payload: array_including(hash_including("postId" => 22)))
649
- expect(output_b).not_to include_flight_row(class: :model,
650
- payload: array_including(hash_including("postId" => 11)))
651
-
652
- # After both renders complete, ensure-block restoration leaves the
653
- # receiver's ivar back at its pre-render value (nil for a fresh
654
- # receiver) — no leftover RenderContext leaking out of the pipeline.
655
- expect(receiver.instance_variable_get(:@__ruact_render_context__)).to be_nil
656
- end
657
- end
658
-
659
- # --- Story 7.7 regression suite — ERB without instance variables ---
660
- #
661
- # The render_erb helper at :46-50 supports empty **locals (zero ivars set
662
- # on the binding receiver), and `:54 plain HTML` exercises that path
663
- # implicitly. This block makes the empty-binding contract explicit so a
664
- # future refactor (e.g. an early `binding_context.eval('instance_variables')`
665
- # check that assumes ≥ 1 ivar) cannot break it silently.
666
- describe "edge cases (Story 7.7) — ERB without instance variables", :story_7_7 do
667
- it "renders plain HTML against a binding with zero instance variables" do
668
- ctx = Object.new
669
- expect(ctx.instance_variables).to eq([])
670
-
671
- output = pipeline.render(
672
- { erb: "<div>hello</div>", binding: ctx.instance_eval { binding } },
673
- mode: :string
674
- )
675
-
676
- expect(output).to match_flight_structure([
677
- { id: 0, class: :model,
678
- payload: ["$", "div", nil, { "children" => "hello" }] }
679
- ])
680
- end
681
-
682
- it "renders ERB that calls a method on self without crashing on an empty binding" do
683
- ctx = Object.new
684
- expect(ctx.instance_variables).to eq([])
685
-
686
- output = pipeline.render(
687
- { erb: "<p><%= self.class.name %></p>", binding: ctx.instance_eval { binding } },
688
- mode: :string
689
- )
690
-
691
- expect(output).to include_flight_row(
692
- class: :model,
693
- payload: array_including(hash_including("children" => "Object"))
694
- )
695
- end
696
-
697
- it "leaves the receiver's @__ruact_render_context__ ivar at nil after a render against an empty binding" do
698
- ctx = Object.new
699
- expect(ctx.instance_variables).to eq([])
700
-
701
- pipeline.render(
702
- { erb: "<%= self.class %>", binding: ctx.instance_eval { binding } },
703
- mode: :string
704
- )
705
-
706
- # The pipeline's ensure block (render_pipeline.rb:174-178) restores
707
- # prev_ctx (nil for a fresh receiver). The ivar may remain *defined*
708
- # on the receiver with value nil — both `[]` and `[:@__ruact_render_context__]`
709
- # are acceptable post-render states; the contract is the *value* is nil,
710
- # not the presence of the symbol in instance_variables.
711
- stashed = ctx.instance_variable_get(:@__ruact_render_context__)
712
- expect(stashed).to be_nil
713
- end
714
- end
715
- end
716
-
717
- # --- #render with html input — ActionView integration path (Story 1.6, consolidated in 7.2) ---
718
-
719
- describe "#render with html input" do
720
- let(:navbar_manifest) do
721
- ClientManifest.from_hash({
722
- "NavBar" => {
723
- "id" => "/NavBar.jsx",
724
- "name" => "NavBar",
725
- "chunks" => ["/NavBar.jsx"]
726
- }
727
- })
728
- end
729
-
730
- it "converts pre-rendered HTML with component placeholders to Flight rows" do
731
- pipeline = described_class.new(navbar_manifest)
732
- ctx = RenderContext.new
733
- token = ctx.register("NavBar", { "currentUser" => 42 })
734
- html = "<div><!-- #{token} --></div>"
735
-
736
- output = pipeline.render({ html: html, render_context: ctx }, mode: :string)
737
-
738
- # NavBar lives nested inside the div's children, so the props hash is
739
- # at payload[3]["children"][3]. Asserting full structure here keeps the
740
- # 7.6 cosmetic-robustness contract while preserving the original test
741
- # intent (placeholder-token resolution + props serialization).
742
- expect(output).to match_flight_structure([
743
- { id: 1, class: :import,
744
- payload: ["/NavBar.jsx", "NavBar", ["/NavBar.jsx"]] },
745
- { id: 0, class: :model,
746
- payload: ["$", "div", nil, {
747
- "children" => ["$", "$L1", nil, { "currentUser" => 42 }]
748
- }] }
749
- ])
750
- end
751
-
752
- it "eagerly captures registry so further mutation does not affect the Enumerator" do
753
- pipeline = described_class.new(navbar_manifest)
754
- ctx = RenderContext.new
755
- ctx.register("NavBar", { "currentUser" => 1 })
756
- token = ctx.components.first[:token]
757
- html = "<div><!-- #{token} --></div>"
758
-
759
- enumerator = pipeline.render({ html: html, render_context: ctx }, mode: :stream)
760
- # Mutating the context after #render should not affect the captured registry.
761
- ctx.components.clear
762
-
763
- expect { enumerator.to_a }.not_to raise_error
764
- expect(enumerator.to_a.join).to include_flight_row(
765
- class: :import, payload: array_including("/NavBar.jsx")
766
- )
767
- end
768
-
769
- it "produces the same Flight output as the erb-input path for equivalent input" do
770
- # Build the same HTML that #render with erb input would produce for <NavBar />
771
- # and verify the html-input path produces the same Flight output.
772
- pipeline_erb = described_class.new(navbar_manifest)
773
- pipeline_html = described_class.new(navbar_manifest)
774
-
775
- pipeline_erb.render({ erb: "<NavBar currentUser={1} />", binding: binding }, mode: :string)
776
-
777
- ctx = RenderContext.new
778
- ctx.register("NavBar", { "currentUser" => 1 })
779
- token = ctx.components.first[:token]
780
- html = "<!-- #{token} -->"
781
- html_output = pipeline_html.render({ html: html, render_context: ctx }, mode: :string)
782
-
783
- # Both should contain the NavBar import row and the root model row (id: 0).
784
- # html_output here is the raw Flight wire (mode: :string returns wire bytes,
785
- # not an HTML shell), so structural matchers apply. Original assertion was
786
- # `match(/0:\[/)` which encoded "the root row exists"; preserve the id: 0
787
- # constraint via include_flight_row so a future change that emitted only a
788
- # nested model row (without root) would still fail.
789
- expect(html_output).to include_flight_row(class: :import, payload: array_including("/NavBar.jsx"))
790
- expect(html_output).to include_flight_row(id: 0, class: :model)
791
- end
792
-
793
- it "returns an Enumerator (lazy) when mode is :stream" do
794
- pipeline = described_class.new(manifest)
795
- html = "<div><p>Hello</p></div>"
796
-
797
- result = pipeline.render({ html: html, render_context: RenderContext.new }, mode: :stream)
798
-
799
- expect(result).to be_a(Enumerator)
800
- end
801
- end
802
-
803
- # --- #render — single coherent entry point (Story 7.2) ---
804
- describe "#render contract" do
805
- let(:erb_source) { "<LikeButton />" }
806
- let(:erb_binding) { Object.new.instance_eval { binding } }
807
- let(:html_input) { "<!-- __RUACT_0__ -->" }
808
- let(:render_ctx) { RenderContext.new.tap { |c| c.register("LikeButton", {}) } }
809
-
810
- describe "input validation" do
811
- it "raises ArgumentError when :erb is given without sibling :binding" do
812
- expect { pipeline.render({ erb: erb_source }, mode: :string) }
813
- .to raise_error(ArgumentError, /sibling :binding/)
814
- end
815
-
816
- it "raises ArgumentError when :html is given without sibling :render_context" do
817
- expect { pipeline.render({ html: html_input }, mode: :string) }
818
- .to raise_error(ArgumentError, /sibling :render_context/)
819
- end
820
-
821
- it "raises ArgumentError when input mixes :erb and :html keys" do
822
- expect do
823
- pipeline.render(
824
- { erb: erb_source, binding: erb_binding, html: html_input, render_context: render_ctx },
825
- mode: :string
826
- )
827
- end.to raise_error(ArgumentError, /cannot mix :erb and :html/)
828
- end
829
-
830
- it "raises ArgumentError when input has neither :erb nor :html" do
831
- expect { pipeline.render({}, mode: :string) }
832
- .to raise_error(ArgumentError, /must include either :erb .* or :html/)
833
- end
834
-
835
- it "raises ArgumentError when input is not a Hash" do
836
- expect { pipeline.render(nil, mode: :string) }
837
- .to raise_error(ArgumentError, /must be a Hash/)
838
- end
839
-
840
- it "raises ArgumentError on unknown :mode value" do
841
- expect { pipeline.render({ erb: erb_source, binding: erb_binding }, mode: :weird) }
842
- .to raise_error(ArgumentError, /unknown render mode :weird.*expected one of/)
843
- end
844
-
845
- it "raises ArgumentError when :erb is not a String" do
846
- expect { pipeline.render({ erb: 42, binding: erb_binding }, mode: :string) }
847
- .to raise_error(ArgumentError, /:erb must be a String/)
848
- end
849
-
850
- it "raises ArgumentError when :binding is not a Binding" do
851
- expect { pipeline.render({ erb: erb_source, binding: "not a binding" }, mode: :string) }
852
- .to raise_error(ArgumentError, /:binding must be a Binding/)
853
- end
854
-
855
- it "raises ArgumentError when :html is not a String" do
856
- expect { pipeline.render({ html: 42, render_context: render_ctx }, mode: :string) }
857
- .to raise_error(ArgumentError, /:html must be a String/)
858
- end
859
-
860
- it "raises ArgumentError when :render_context is not a Ruact::RenderContext" do
861
- expect { pipeline.render({ html: html_input, render_context: Object.new }, mode: :string) }
862
- .to raise_error(ArgumentError, /:render_context must be a Ruact::RenderContext/)
863
- end
864
-
865
- it "raises ArgumentError when input contains extra keys beyond the documented shapes" do
866
- expect do
867
- pipeline.render({ erb: erb_source, binding: erb_binding, foo: 1 }, mode: :string)
868
- end.to raise_error(ArgumentError, /unsupported keys: \[:foo\]/)
869
- end
870
-
871
- it "all validation errors reference the RenderPipeline#render docstring" do
872
- expect { pipeline.render(nil, mode: :string) }
873
- .to raise_error(ArgumentError, /RenderPipeline#render docstring/)
874
- end
875
- end
876
-
877
- describe "mode contract" do
878
- it "returns a String when mode is :string (erb input)" do
879
- out = pipeline.render({ erb: erb_source, binding: erb_binding }, mode: :string)
880
- expect(out).to be_a(String)
881
- expect(out).to include_flight_row(class: :import, payload: array_including("LikeButton"))
882
- end
883
-
884
- it "returns an Enumerator when mode is :stream (erb input)" do
885
- out = pipeline.render({ erb: erb_source, binding: erb_binding }, mode: :stream)
886
- expect(out).to be_a(Enumerator)
887
- joined = out.to_a.join
888
- expect(joined).to include_flight_row(class: :import, payload: array_including("LikeButton"))
889
- end
890
-
891
- it "returns a String when mode is :string (html input)" do
892
- out = pipeline.render({ html: html_input, render_context: render_ctx }, mode: :string)
893
- expect(out).to be_a(String)
894
- expect(out).to include_flight_row(class: :import, payload: array_including("LikeButton"))
895
- end
896
-
897
- it "returns an Enumerator when mode is :stream (html input)" do
898
- out = pipeline.render({ html: html_input, render_context: render_ctx }, mode: :stream)
899
- expect(out).to be_a(Enumerator)
900
- joined = out.to_a.join
901
- expect(joined).to include_flight_row(class: :import, payload: array_including("LikeButton"))
902
- end
903
-
904
- it "defaults mode to :string when omitted" do
905
- out = pipeline.render({ erb: erb_source, binding: erb_binding })
906
- expect(out).to be_a(String)
907
- end
908
- end
909
-
910
- describe "eager-capture invariant (html input)" do
911
- it "does not reach back into the RenderContext after #render returns" do
912
- ctx = RenderContext.new
913
- ctx.register("LikeButton", { "postId" => 1 })
914
-
915
- enum = pipeline.render({ html: "<!-- __RUACT_0__ -->", render_context: ctx }, mode: :stream)
916
-
917
- # Mutating the context after #render returns must not affect the captured registry.
918
- ctx.register("LikeButton", { "postId" => 999 })
919
-
920
- out = enum.to_a.join
921
- expect(out).to include_flight_row(class: :model, payload: array_including(hash_including("postId" => 1)))
922
- expect(out).not_to include_flight_row(class: :model,
923
- payload: array_including(hash_including("postId" => 999)))
924
- end
925
- end
926
- end
927
- end
928
- end