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.
- checksums.yaml +4 -4
- data/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +86 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1680 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +89 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +330 -0
- data/lib/ruact/server_functions/codegen_v2.rb +176 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +188 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +113 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +248 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +75 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +446 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +429 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +3 -2
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
- metadata +87 -6
- data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
- data/lib/ruact/component_registry.rb +0 -31
- 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
|
-
|
|
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.
|
|
49
|
+
pipeline.render({ erb: erb_source, binding: ctx.instance_eval { binding } }, mode: :string)
|
|
38
50
|
end
|
|
39
51
|
|
|
40
|
-
describe "
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
98
|
+
import_idx = lines.index { |l| l.include?(":I[") }
|
|
99
|
+
model_idx = lines.index { |l| l.start_with?("0:") }
|
|
76
100
|
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
135
|
+
threads.each(&:join)
|
|
109
136
|
|
|
110
|
-
|
|
137
|
+
expect(errors).to be_empty, "Threads raised: #{errors.map(&:message).join(', ')}"
|
|
111
138
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
#
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
387
|
+
# --- Story 2.3: Serializable integration ---
|
|
248
388
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
end
|
|
402
|
+
let(:serializable_post) do
|
|
403
|
+
Class.new do
|
|
404
|
+
include Ruact::Serializable
|
|
260
405
|
|
|
261
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
end
|
|
408
|
+
def initialize
|
|
409
|
+
@id = 1
|
|
410
|
+
@title = "Hello"
|
|
411
|
+
end
|
|
271
412
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
expect(output).not_to include('"active":"false"')
|
|
276
|
-
end
|
|
413
|
+
def self.name
|
|
414
|
+
"Post"
|
|
415
|
+
end
|
|
277
416
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
expect(output).not_to include('"value":"nil"')
|
|
282
|
-
end
|
|
417
|
+
ruact_props :id, :title
|
|
418
|
+
end.new
|
|
419
|
+
end
|
|
283
420
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
348
|
-
"
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
717
|
+
# --- #render with html input — ActionView integration path (Story 1.6, consolidated in 7.2) ---
|
|
383
718
|
|
|
384
|
-
describe "
|
|
385
|
-
let(:
|
|
719
|
+
describe "#render with html input" do
|
|
720
|
+
let(:navbar_manifest) do
|
|
386
721
|
ClientManifest.from_hash({
|
|
387
|
-
"
|
|
388
|
-
"id" => "/
|
|
389
|
-
"name" => "
|
|
390
|
-
"chunks" => ["/
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
736
|
+
output = pipeline.render({ html: html, render_context: ctx }, mode: :string)
|
|
402
737
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
413
|
-
|
|
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 "
|
|
417
|
-
|
|
418
|
-
|
|
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 "
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
# ---
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|