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
@@ -31,443 +31,897 @@ module Ruact
31
31
 
32
32
  let(:pipeline) { described_class.new(manifest) }
33
33
 
34
- def render(erb_source, **locals)
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)
35
47
  ctx = Object.new
36
48
  locals.each { |k, v| ctx.instance_variable_set("@#{k}", v) }
37
- pipeline.call(erb_source, ctx.instance_eval { binding })
49
+ pipeline.render({ erb: erb_source, binding: ctx.instance_eval { binding } }, mode: :string)
38
50
  end
39
51
 
40
- describe "plain HTML" do
41
- it "serializes a plain HTML element" do
42
- output = render('<div class="hello"><p>World</p></div>')
43
- expect(output).to match(/0:\["\$","div"/)
44
- expect(output).to match(/"className":"hello"/)
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
45
64
  end
46
- end
47
65
 
48
- describe "unknown component" do
49
- it "raises an error when component is not in manifest" do
50
- expect { render("<Button />") }.to raise_error(an_object_satisfying { |e|
51
- e.message.include?("not found in manifest")
52
- })
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
53
72
  end
54
- end
55
-
56
- describe "client component with props" do
57
- it "emits I row, serializes props, and puts root at ID 0" do
58
- output = render("<div><LikeButton postId={@post_id} initialCount={5} /></div>",
59
- post_id: 42)
60
73
 
61
- expect(output).to match(/I\[/)
62
- expect(output).to match(/LikeButton/)
63
- expect(output).to match(/"postId":42/)
64
- expect(output).to match(/"initialCount":5/)
65
- expect(output.lines.last).to start_with("0:"), "last row should be root (id=0)"
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
66
91
  end
67
- end
68
92
 
69
- describe "import row ordering" do
70
- it "emits the I row before the root model row" do
71
- output = render("<LikeButton postId={1} />")
72
- lines = output.lines.map(&:strip).reject(&:empty?)
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?)
73
97
 
74
- import_idx = lines.index { |l| l.include?(":I[") }
75
- model_idx = lines.index { |l| l.start_with?("0:") }
98
+ import_idx = lines.index { |l| l.include?(":I[") }
99
+ model_idx = lines.index { |l| l.start_with?("0:") }
76
100
 
77
- expect(import_idx).to be < model_idx, "I row must come before the root model row"
101
+ expect(import_idx).to be < model_idx, "I row must come before the root model row"
102
+ end
78
103
  end
79
- end
80
104
 
81
- describe "ERB instance variables" do
82
- it "evaluates ERB and passes instance variables into the output" do
83
- output = render("<p><%= @title %></p>", title: "Hello RSC")
84
- expect(output).to match(/"Hello RSC"/)
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
85
113
  end
86
- end
87
114
 
88
- describe "thread safety (NFR8)" do
89
- it "isolates component state across 10 concurrent renders" do
90
- results = Array.new(10)
91
- mutex = Mutex.new
92
- errors = []
93
-
94
- threads = Array.new(10) do |i|
95
- Thread.new do
96
- ctx = Object.new
97
- ctx.instance_variable_set(:@index, i)
98
- output = pipeline.call(
99
- "<LikeButton postId={@index} />",
100
- ctx.instance_eval { binding }
101
- )
102
- mutex.synchronize { results[i] = output }
103
- rescue StandardError => e
104
- mutex.synchronize { errors << e }
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
105
133
  end
106
- end
107
134
 
108
- threads.each(&:join)
135
+ threads.each(&:join)
109
136
 
110
- expect(errors).to be_empty, "Threads raised: #{errors.map(&:message).join(', ')}"
137
+ expect(errors).to be_empty, "Threads raised: #{errors.map(&:message).join(', ')}"
111
138
 
112
- 10.times do |i|
113
- expect(results[i]).to include("\"postId\":#{i}"),
114
- "Thread #{i} must contain postId=#{i} got: #{results[i].inspect}"
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
115
144
  end
116
145
  end
117
- end
118
146
 
119
- describe "determinism (NFR16)" do
120
- it "produces identical byte output on repeated renders of the same view" do
121
- first = render("<div><LikeButton postId={1} /></div>")
122
- second = render("<div><LikeButton postId={1} /></div>")
123
- expect(first).to eq(second)
124
- end
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
125
154
 
126
- it "produces different output for different input data" do
127
- output_a = render("<LikeButton postId={1} />")
128
- output_b = render("<LikeButton postId={2} />")
129
- expect(output_a).not_to eq(output_b)
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
130
161
  end
131
- end
132
162
 
133
- # --- from_html ActionView integration path (Story 1.6) ---
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
134
180
 
135
- describe "#from_html" do
136
- let(:navbar_manifest) do
137
- ClientManifest.from_hash({
138
- "NavBar" => {
139
- "id" => "/NavBar.jsx",
140
- "name" => "NavBar",
141
- "chunks" => ["/NavBar.jsx"]
142
- }
143
- })
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
144
203
  end
145
204
 
146
- it "converts pre-rendered HTML with component placeholders to Flight rows" do
147
- pipeline = described_class.new(navbar_manifest)
148
- ComponentRegistry.start
149
- token = ComponentRegistry.register("NavBar", { "currentUser" => 42 })
150
- html = "<div><!-- #{token} --></div>"
205
+ # --- Prop type integration specs (AC#1–#7) ---
151
206
 
152
- output = pipeline.from_html(html).to_a.join
153
- ComponentRegistry.reset
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
154
215
 
155
- expect(output).to include("NavBar")
156
- expect(output).to include('"currentUser":42')
157
- end
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
158
221
 
159
- it "eagerly captures registry so ComponentRegistry can be reset before Enumerator is consumed" do
160
- pipeline = described_class.new(navbar_manifest)
161
- ComponentRegistry.start
162
- ComponentRegistry.register("NavBar", { "currentUser" => 1 })
163
- token = ComponentRegistry.components.first[:token]
164
- html = "<div><!-- #{token} --></div>"
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
165
241
 
166
- enumerator = pipeline.from_html(html)
167
- ComponentRegistry.reset # Reset BEFORE consuming the enumerator
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
168
249
 
169
- expect { enumerator.to_a }.not_to raise_error
170
- expect(enumerator.to_a.join).to include("NavBar")
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
171
273
  end
172
274
 
173
- it "produces the same Flight output as call() for equivalent input" do
174
- # Build the same HTML that RenderPipeline#call would produce for <NavBar />
175
- # and verify from_html produces the same Flight output.
176
- pipeline_erb = described_class.new(navbar_manifest)
177
- pipeline_html = described_class.new(navbar_manifest)
275
+ # --- Loop / local variables spec (AC#8) ---
178
276
 
179
- pipeline_erb.call("<NavBar currentUser={1} />", binding)
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
180
282
 
181
- ComponentRegistry.start
182
- ComponentRegistry.register("NavBar", { "currentUser" => 1 })
183
- token = ComponentRegistry.components.first[:token]
184
- html = "<!-- #{token} -->"
185
- html_output = pipeline_html.from_html(html).to_a.join
186
- ComponentRegistry.reset
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
187
295
 
188
- # Both should contain the NavBar import row and a root model row
189
- expect(html_output).to include("NavBar")
190
- expect(html_output).to match(/0:\[/)
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
191
317
  end
192
318
 
193
- it "returns an Enumerator (lazy)" do
194
- pipeline = described_class.new(manifest)
195
- ComponentRegistry.start
196
- html = "<div><p>Hello</p></div>"
319
+ # --- Story 2.2: as_json integration ---
197
320
 
198
- result = pipeline.from_html(html)
199
- ComponentRegistry.reset
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) }
200
333
 
201
- expect(result).to be_a(Enumerator)
202
- end
203
- end
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
204
345
 
205
- # --- Dual-path resolution specs (Story 1.5) ---
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
206
353
 
207
- describe "dual-path resolution via controller_path" do
208
- let(:dual_manifest) do
209
- ClientManifest.from_hash({
210
- "LikeButton" => {
211
- "id" => "/LikeButton.jsx",
212
- "name" => "LikeButton",
213
- "chunks" => ["/LikeButton.jsx"]
214
- },
215
- "posts/_like_button" => {
216
- "id" => "/posts/_like_button.jsx",
217
- "name" => "default",
218
- "chunks" => ["/posts/_like_button.jsx"]
219
- }
220
- })
221
- end
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
222
359
 
223
- it "uses co-located component when controller_path matches (AC#2, AC#3)" do
224
- pipeline = described_class.new(dual_manifest, controller_path: "posts")
225
- ctx = Object.new
226
- output = pipeline.call("<LikeButton />", ctx.instance_eval { binding })
227
- expect(output).to include("/posts/_like_button.jsx")
228
- expect(output).not_to include("/LikeButton.jsx")
229
- end
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
230
365
 
231
- it "uses shared component when no controller_path given (AC#1)" do
232
- pipeline = described_class.new(dual_manifest)
233
- ctx = Object.new
234
- output = pipeline.call("<LikeButton />", ctx.instance_eval { binding })
235
- expect(output).to include("/LikeButton.jsx")
236
- expect(output).not_to include("/posts/_like_button.jsx")
237
- end
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
238
378
 
239
- it "falls back to shared when controller_path has no co-located key (AC#4)" do
240
- pipeline = described_class.new(dual_manifest, controller_path: "comments")
241
- ctx = Object.new
242
- output = pipeline.call("<LikeButton />", ctx.instance_eval { binding })
243
- expect(output).to include("/LikeButton.jsx")
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
244
385
  end
245
- end
246
386
 
247
- # --- Prop type integration specs (AC#1–#7) ---
387
+ # --- Story 2.3: Serializable integration ---
248
388
 
249
- describe "prop types via ERB (AC#1–#7)" do
250
- it "integer prop is a JSON number, not a string (AC#1)" do
251
- output = render("<LikeButton postId={@count} />", count: 42)
252
- expect(output).to include('"postId":42')
253
- expect(output).not_to include('"postId":"42"')
254
- end
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) }
255
401
 
256
- it "string prop is a JSON string (AC#2)" do
257
- output = render("<LikeButton label={@title} />", title: "hello")
258
- expect(output).to include('"label":"hello"')
259
- end
402
+ let(:serializable_post) do
403
+ Class.new do
404
+ include Ruact::Serializable
260
405
 
261
- it "dollar-prefixed string is escaped with one extra $ (AC#3)" do
262
- output = render("<LikeButton label={@price} />", price: "$9.99")
263
- expect(output).to include('"label":"$$9.99"')
264
- end
406
+ attr_reader :id, :title
265
407
 
266
- it "boolean true prop is a JSON boolean literal (AC#4)" do
267
- output = render("<LikeButton enabled={true} />")
268
- expect(output).to include('"enabled":true')
269
- expect(output).not_to include('"enabled":"true"')
270
- end
408
+ def initialize
409
+ @id = 1
410
+ @title = "Hello"
411
+ end
271
412
 
272
- it "boolean false prop is a JSON boolean literal (AC#4)" do
273
- output = render("<LikeButton active={false} />")
274
- expect(output).to include('"active":false')
275
- expect(output).not_to include('"active":"false"')
276
- end
413
+ def self.name
414
+ "Post"
415
+ end
277
416
 
278
- it "nil prop becomes JSON null (AC#5)" do
279
- output = render("<LikeButton value={nil} />")
280
- expect(output).to include('"value":null')
281
- expect(output).not_to include('"value":"nil"')
282
- end
417
+ ruact_props :id, :title
418
+ end.new
419
+ end
283
420
 
284
- it "array prop has correctly typed elements (AC#6)" do
285
- output = render("<LikeButton items={[1, \"a\", true, nil]} />")
286
- expect(output).to include('"items":[1,"a",true,null]')
287
- end
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
288
428
 
289
- it "hash prop produces a JSON object with correct types (AC#7)" do
290
- output = render("<LikeButton opts={{debug: true, count: 5, label: \"x\"}} />")
291
- expect(output).to include('"opts":{"debug":true,"count":5,"label":"x"}')
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
292
433
  end
293
- end
294
434
 
295
- # --- Loop / local variables spec (AC#8) ---
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) }
296
453
 
297
- describe "looplocal variables as props (AC#8)" do
298
- let(:post_struct) { Struct.new(:title, :id) }
299
- let(:posts) do
300
- [post_struct.new("First", 1), post_struct.new("Second", 2), post_struct.new("Third", 3)]
301
- end
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
302
460
 
303
- let(:loop_output) do
304
- pipeline_with_cards = described_class.new(manifest_with_post_card)
305
- ctx = Object.new
306
- ctx.instance_variable_set(:@posts, posts)
307
- pipeline_with_cards.call(
308
- "<% @posts.each do |post| %><PostCard title={post.title} id={post.id} /><% end %>",
309
- ctx.instance_eval { binding }
310
- )
311
- end
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
312
467
 
313
- it "each PostCard receives its correct title prop" do
314
- expect(loop_output).to include('"title":"First"')
315
- expect(loop_output).to include('"title":"Second"')
316
- expect(loop_output).to include('"title":"Third"')
317
- end
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
318
481
 
319
- it "each PostCard receives its correct id prop as a JSON number" do
320
- expect(loop_output).to include('"id":1')
321
- expect(loop_output).to include('"id":2')
322
- expect(loop_output).to include('"id":3')
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
323
495
  end
324
- end
325
496
 
326
- # --- Story 2.2: as_json integration ---
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
327
516
 
328
- describe "as_json integration (Story 2.2)" do
329
- let(:as_json_manifest) do
330
- ClientManifest.from_hash({
331
- "PostCard" => {
332
- "id" => "/assets/PostCard-abc.js",
333
- "name" => "PostCard",
334
- "chunks" => ["/assets/PostCard-abc.js"]
335
- }
336
- })
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
337
536
  end
338
- let(:fake_logger) { instance_double(::Logger, warn: nil) }
339
- let(:pipeline) { described_class.new(as_json_manifest, logger: fake_logger) }
340
537
 
341
- let(:post_like_object) do
342
- Class.new do
343
- def as_json
344
- { "id" => 1, "title" => "Hello" }
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
345
577
  end
346
578
 
347
- def self.name
348
- "Post"
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
+ ""
349
610
  end
350
- end.new
351
- end
352
611
 
353
- it "serializes as_json object as a JSON object prop (AC#1)" do
354
- output = render("<PostCard post={@the_post} />", the_post: post_like_object)
355
- expect(output).to include('"id":1')
356
- expect(output).to include('"title":"Hello"')
357
- end
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
358
629
 
359
- it "emits [ruact] warning via the injected logger (AC#1, #3)" do
360
- render("<PostCard post={@the_post} />", the_post: post_like_object)
361
- expect(fake_logger).to have_received(:warn)
362
- .with(match(/\[ruact\] WARNING: Post serialized via as_json/))
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
363
657
  end
364
658
 
365
- it "warning includes attribute names (AC#1)" do
366
- render("<PostCard post={@the_post} />", the_post: post_like_object)
367
- expect(fake_logger).to have_received(:warn)
368
- .with(match(/ALL attributes exposed to client: id, title/))
369
- end
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
370
681
 
371
- context "with strict_serialization pipeline" do
372
- let(:pipeline) { described_class.new(as_json_manifest, logger: fake_logger) }
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
+ )
373
690
 
374
- it "raises SerializationError when strict_serialization: true (AC#2)" do
375
- allow(Ruact.config).to receive(:strict_serialization).and_return(true)
376
- expect { render("<PostCard post={@the_post} />", the_post: post_like_object) }
377
- .to raise_error(Ruact::SerializationError, /strict_serialization/)
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
378
713
  end
379
714
  end
380
715
  end
381
716
 
382
- # --- Story 2.3: Serializable integration ---
717
+ # --- #render with html input — ActionView integration path (Story 1.6, consolidated in 7.2) ---
383
718
 
384
- describe "Serializable integration (Story 2.3)" do
385
- let(:serializable_manifest) do
719
+ describe "#render with html input" do
720
+ let(:navbar_manifest) do
386
721
  ClientManifest.from_hash({
387
- "PostCard" => {
388
- "id" => "/assets/PostCard-abc.js",
389
- "name" => "PostCard",
390
- "chunks" => ["/assets/PostCard-abc.js"]
722
+ "NavBar" => {
723
+ "id" => "/NavBar.jsx",
724
+ "name" => "NavBar",
725
+ "chunks" => ["/NavBar.jsx"]
391
726
  }
392
727
  })
393
728
  end
394
- let(:fake_logger_s) { instance_double(::Logger, warn: nil) }
395
- let(:pipeline) { described_class.new(serializable_manifest, logger: fake_logger_s) }
396
729
 
397
- let(:serializable_post) do
398
- Class.new do
399
- include Ruact::Serializable
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>"
400
735
 
401
- attr_reader :id, :title
736
+ output = pipeline.render({ html: html, render_context: ctx }, mode: :string)
402
737
 
403
- def initialize
404
- @id = 1
405
- @title = "Hello"
406
- end
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
407
751
 
408
- def self.name
409
- "Post"
410
- end
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
411
762
 
412
- rsc_props :id, :title
413
- end.new
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
+ )
414
767
  end
415
768
 
416
- it "serializes only declared props (AC#1)" do
417
- output = render("<PostCard post={@the_post} />", the_post: serializable_post)
418
- expect(output).to include('"id":1', '"title":"Hello"')
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)
419
791
  end
420
792
 
421
- it "does NOT emit [ruact] warning (AC#3)" do
422
- render("<PostCard post={@the_post} />", the_post: serializable_post)
423
- expect(fake_logger_s).not_to have_received(:warn)
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)
424
800
  end
425
801
  end
426
802
 
427
- # --- Story 2.1: useState/useEffect/event handler prop types ---
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
428
815
 
429
- describe "client components with hook prop types (Story 2.1)" do
430
- let(:manifest_with_hooks) do
431
- ClientManifest.from_hash({
432
- "CounterButton" => {
433
- "id" => "/assets/CounterButton-abc.js",
434
- "name" => "CounterButton",
435
- "chunks" => ["/assets/CounterButton-abc.js"]
436
- },
437
- "SearchInput" => {
438
- "id" => "/assets/SearchInput-abc.js",
439
- "name" => "SearchInput",
440
- "chunks" => ["/assets/SearchInput-abc.js"]
441
- }
442
- })
443
- end
444
- let(:pipeline) { described_class.new(manifest_with_hooks) }
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
445
820
 
446
- it "serializes string prop (AC#3 useState initial string, onChange placeholder)" do
447
- output = render('<SearchInput placeholder={"Search..."} />')
448
- expect(output).to match(/"placeholder":"Search\.\.\."/)
449
- end
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
450
829
 
451
- it "serializes boolean true prop for useState initial value (AC#1)" do
452
- output = render("<CounterButton enabled={true} />")
453
- expect(output).to match(/"enabled":true/)
454
- end
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
455
844
 
456
- it "serializes boolean false prop for disabled state (AC#1)" do
457
- output = render("<CounterButton disabled={false} />")
458
- expect(output).to match(/"disabled":false/)
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
459
875
  end
460
876
 
461
- it "serializes nil prop as JSON null — no hydration mismatch for absent optionals (AC#4)" do
462
- output = render("<CounterButton initialCount={nil} />")
463
- expect(output).to match(/"initialCount":null/)
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
464
908
  end
465
909
 
466
- it "serializes mixed props (integer + string + boolean) in a single component (AC#1, #3, #4)" do
467
- output = render('<CounterButton initialCount={0} label={"Votes"} disabled={false} />')
468
- expect(output).to match(/"initialCount":0/)
469
- expect(output).to match(/"label":"Votes"/)
470
- expect(output).to match(/"disabled":false/)
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
471
925
  end
472
926
  end
473
927
  end