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