ruact 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +123 -48
  3. data/CONTRIBUTING.md +233 -0
  4. data/README.md +16 -6
  5. data/RELEASING.md +125 -139
  6. data/lib/ruact/packaging.rb +68 -0
  7. data/lib/ruact/version.rb +1 -1
  8. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +321 -0
  9. data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
  10. data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
  11. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
  12. metadata +6 -107
  13. data/.codecov.yml +0 -31
  14. data/.github/workflows/ci.yml +0 -284
  15. data/.github/workflows/server-functions-bench.yml +0 -54
  16. data/.rubocop.yml +0 -107
  17. data/.rubocop_todo.yml +0 -63
  18. data/Rakefile +0 -10
  19. data/bench/server_functions_dispatch_bench.rb +0 -276
  20. data/bench/server_functions_dispatch_bench.results.md +0 -150
  21. data/docs/internal/README.md +0 -9
  22. data/docs/internal/decisions/server-functions-api.md +0 -2236
  23. data/spec/benchmarks/baseline.json +0 -1
  24. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -92
  25. data/spec/fixtures/flight/README.md +0 -136
  26. data/spec/fixtures/flight/array.txt +0 -1
  27. data/spec/fixtures/flight/as_json_object.txt +0 -2
  28. data/spec/fixtures/flight/bigint.txt +0 -1
  29. data/spec/fixtures/flight/boolean_false.txt +0 -1
  30. data/spec/fixtures/flight/boolean_true.txt +0 -1
  31. data/spec/fixtures/flight/client_component_with_props.txt +0 -2
  32. data/spec/fixtures/flight/client_reference.txt +0 -2
  33. data/spec/fixtures/flight/hash.txt +0 -1
  34. data/spec/fixtures/flight/infinity.txt +0 -1
  35. data/spec/fixtures/flight/nan.txt +0 -1
  36. data/spec/fixtures/flight/negative_infinity.txt +0 -1
  37. data/spec/fixtures/flight/nil.txt +0 -1
  38. data/spec/fixtures/flight/number_float.txt +0 -1
  39. data/spec/fixtures/flight/number_integer.txt +0 -1
  40. data/spec/fixtures/flight/react_element_no_props.txt +0 -1
  41. data/spec/fixtures/flight/redirect_row.txt +0 -1
  42. data/spec/fixtures/flight/serializable_object.txt +0 -2
  43. data/spec/fixtures/flight/string_basic.txt +0 -1
  44. data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
  45. data/spec/fixtures/flight/undefined.txt +0 -1
  46. data/spec/fixtures/readme/children-error.html.erb +0 -3
  47. data/spec/fixtures/readme/children-error.txt +0 -1
  48. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
  49. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
  50. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
  51. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
  52. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
  53. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
  54. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
  55. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
  56. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
  57. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
  58. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
  59. data/spec/readme_demo_message_spec.rb +0 -67
  60. data/spec/readme_spec.rb +0 -282
  61. data/spec/ruact/client_manifest_spec.rb +0 -270
  62. data/spec/ruact/component_contract_spec.rb +0 -119
  63. data/spec/ruact/configuration_spec.rb +0 -518
  64. data/spec/ruact/controller_request_spec.rb +0 -671
  65. data/spec/ruact/controller_spec.rb +0 -343
  66. data/spec/ruact/doctor_spec.rb +0 -769
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
  68. data/spec/ruact/erb_preprocessor_spec.rb +0 -361
  69. data/spec/ruact/errors_spec.rb +0 -93
  70. data/spec/ruact/flight/renderer_spec.rb +0 -133
  71. data/spec/ruact/flight/serializer_spec.rb +0 -494
  72. data/spec/ruact/html_converter_spec.rb +0 -375
  73. data/spec/ruact/install_generator_spec.rb +0 -1549
  74. data/spec/ruact/layout_source_spec.rb +0 -108
  75. data/spec/ruact/manifest_resolver_spec.rb +0 -174
  76. data/spec/ruact/query_request_spec.rb +0 -706
  77. data/spec/ruact/query_spec.rb +0 -105
  78. data/spec/ruact/railtie_spec.rb +0 -155
  79. data/spec/ruact/render_context_spec.rb +0 -58
  80. data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
  81. data/spec/ruact/render_pipeline_spec.rb +0 -928
  82. data/spec/ruact/scaffold_generator_spec.rb +0 -1849
  83. data/spec/ruact/serializable_spec.rb +0 -179
  84. data/spec/ruact/server_bucket_request_spec.rb +0 -785
  85. data/spec/ruact/server_function_name_spec.rb +0 -53
  86. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
  87. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
  88. data/spec/ruact/server_functions/codegen_spec.rb +0 -397
  89. data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
  90. data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
  91. data/spec/ruact/server_functions/introspection_spec.rb +0 -135
  92. data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
  93. data/spec/ruact/server_functions/query_context_spec.rb +0 -72
  94. data/spec/ruact/server_functions/query_source_spec.rb +0 -193
  95. data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
  96. data/spec/ruact/server_functions/rake_spec.rb +0 -86
  97. data/spec/ruact/server_functions/route_source_spec.rb +0 -202
  98. data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
  99. data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
  100. data/spec/ruact/server_rescue_request_spec.rb +0 -416
  101. data/spec/ruact/server_spec.rb +0 -179
  102. data/spec/ruact/server_upload_request_spec.rb +0 -311
  103. data/spec/ruact/signed_references_spec.rb +0 -164
  104. data/spec/ruact/string_distance_spec.rb +0 -38
  105. data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
  106. data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
  107. data/spec/ruact/testing/no_production_load_spec.rb +0 -41
  108. data/spec/ruact/validation_errors_spec.rb +0 -116
  109. data/spec/ruact/view_helper_spec.rb +0 -131
  110. data/spec/spec_helper.rb +0 -77
  111. data/spec/support/fixtures/pixel.png +0 -0
  112. data/spec/support/flight_wire_parser.rb +0 -21
  113. data/spec/support/flight_wire_parser_spec.rb +0 -93
  114. data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
  115. data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
  116. data/spec/support/rails_stub.rb +0 -115
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ # What goes inside the published `.gem`, as a predicate rather than a list.
5
+ #
6
+ # There is exactly one writer of this rule. `ruact.gemspec` calls it to build
7
+ # `spec.files`, and `bin/release-gate` calls it to decide whether a pull
8
+ # request changed the bytes a consumer installs. A hand-kept path list on
9
+ # either side would be a second copy of the packaging rule, and it would drift
10
+ # from this one the first time the rule changed — which is the whole reason
11
+ # the gate does not carry its own.
12
+ #
13
+ # This file is **not** required from `lib/ruact.rb`. It is build-time code
14
+ # that happens to live under `lib/` so the gemspec can `require_relative` into
15
+ # it; nothing at runtime loads it, and nothing at runtime may come to depend
16
+ # on it.
17
+ #
18
+ # WHAT IT DOES NOT PROMISE
19
+ #
20
+ # That the path exists, is tracked, or is readable. It answers a question
21
+ # about a path's shape and nothing else; both callers hand it paths that
22
+ # `git` has already vouched for.
23
+ module Packaging
24
+ # Directory trees a consumer of the installed gem actually reaches:
25
+ # `lib/` is the library, `sig/` is its RBS surface, and
26
+ # `vendor/javascript/` is public surface too — the bundled Vite plugin and
27
+ # browser runtime are resolved by filesystem path off the installed gem, so
28
+ # a release that left them out would generate imports to files that do not
29
+ # exist on the consumer's disk.
30
+ TREES = %w[lib/ sig/ vendor/javascript/].freeze
31
+
32
+ # `vendor/bundle/` is the local Bundler install directory. It is not under
33
+ # any of {TREES} today; the exclusion is here so that widening a tree above
34
+ # cannot quietly start packaging it.
35
+ EXCLUDED_TREES = %w[vendor/bundle/].freeze
36
+
37
+ # The documents that ship beside the code, expressed as a shape rather than
38
+ # a roster: a tracked file at the top level whose extension is `.md` or
39
+ # `.txt`. That covers the readme, the changelog, the licence, the security
40
+ # policy and both guides, and it keeps covering the next one without anyone
41
+ # remembering to add it here.
42
+ #
43
+ # It also decides the near misses by construction. `ruact.gemspec`,
44
+ # `Gemfile`, `Gemfile.lock`, `Rakefile`, `.rubocop.yml` and `.gitignore` are
45
+ # all top-level and none of them is a document, so none of them ships.
46
+ ROOT_DOCUMENT = %r{\A[^/]+\.(?:md|txt)\z}i
47
+
48
+ # Does this repository-relative path go inside the published gem?
49
+ #
50
+ # @param path [String] a repository-relative path, as `git ls-files` and
51
+ # `git diff --name-only` both emit it (forward slashes, no leading `./`)
52
+ # @return [Boolean] true when the path is part of what a consumer installs
53
+ def self.packaged?(path)
54
+ return false if EXCLUDED_TREES.any? { |tree| path.start_with?(tree) }
55
+
56
+ TREES.any? { |tree| path.start_with?(tree) } || path.match?(ROOT_DOCUMENT)
57
+ end
58
+
59
+ # The packaged subset of a list of repository-relative paths, order
60
+ # preserved.
61
+ #
62
+ # @param paths [Enumerable<String>] repository-relative paths
63
+ # @return [Array<String>] those of them that go inside the published gem
64
+ def self.packaged_paths(paths)
65
+ paths.select { |path| packaged?(path) }
66
+ end
67
+ end
68
+ end
data/lib/ruact/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruact
4
- VERSION = "0.0.10"
4
+ VERSION = "0.0.12"
5
5
  end
@@ -0,0 +1,321 @@
1
+ // Story 17.0a (issue #62) — the Flight client must not collapse arrays.
2
+ //
3
+ // `flight-client.js` was the only runtime module in this package without a unit
4
+ // test, and the defect this file pins is what that gap allowed: `_buildTree`
5
+ // applied a single-element unwrap (`items.length === 1 ? items[0] : items`) to
6
+ // EVERY array it walked. `_buildTree` is a generic value walker with no
7
+ // positional information, so it could not tell a `children` array from a data
8
+ // array sitting in a prop — a list prop holding exactly one row arrived at the
9
+ // client component as the row itself.
10
+ //
11
+ // What this file covers:
12
+ //
13
+ // 1. Data props — 0 / 1 / 2 elements, at top level and nested, arrive with
14
+ // their arity intact. The n=1 cases are the regression; n=0 and n=2 are
15
+ // guards that the fix does not over-correct in the other direction.
16
+ // 2. Children — 0 / 1 / 2, asserting the REBUILT content (type, key, nested
17
+ // children), not just arity, so a rebuild returning nulls cannot pass.
18
+ // Children keep their arity too: the server collapses a lone TAG-NESTED
19
+ // child before the wire (`html_converter.rb:188`, Suspense at `:202`), but
20
+ // an explicit `children={[...]}` prop reaches the client as an array and
21
+ // now stays one. See the contract note on that describe block.
22
+ // 3. The gem-produced fixture — the same assertion driven by wire bytes the
23
+ // Ruby serializer wrote, not by a literal typed here.
24
+ //
25
+ // Isolation: `flight-client.js` keeps `pendingChunks` and `lazyCache` as
26
+ // module-level Maps that survive between tests in one process. `lazyCache` is
27
+ // memoized by rowId, so two cases reusing an id would leak into each other.
28
+ // `clearPendingChunks()` in `beforeEach` is what keeps this file order-
29
+ // independent. Vitest is not configured to shuffle, so nothing here depends on
30
+ // that guard today — it is insurance against a case that reuses a row id, and
31
+ // against shuffling being turned on later.
32
+
33
+ import { describe, it, expect, beforeEach } from "vitest";
34
+ import fs from "node:fs";
35
+ import path from "node:path";
36
+ import {
37
+ buildTree,
38
+ createFromFlightPayload,
39
+ clearPendingChunks,
40
+ } from "./runtime/flight-client.js";
41
+
42
+ // A stand-in client component. Never rendered — these tests assert the props
43
+ // the client BUILDS, which is the contract the component then consumes.
44
+ const TaskList = () => null;
45
+ const PostList = () => null; // the fixture's component — see the last describe
46
+ const MODULE_REGISTRY = {
47
+ "/TaskList.jsx": { TaskList },
48
+ "/PostList.jsx": { PostList },
49
+ };
50
+
51
+ const EMPTY_ROWS = new Map();
52
+
53
+ /** Wire payload: one import row + a root element carrying `props`. */
54
+ function payloadWithProps(props) {
55
+ return [
56
+ '1:I["/TaskList.jsx","TaskList"]',
57
+ `0:["$","$L1",null,${JSON.stringify(props)}]`,
58
+ "",
59
+ ].join("\n");
60
+ }
61
+
62
+ beforeEach(() => {
63
+ clearPendingChunks();
64
+ });
65
+
66
+ describe("Story 17.0a — data props keep their arity", () => {
67
+ it("a prop holding ONE item arrives as a one-element array", () => {
68
+ const tree = createFromFlightPayload(
69
+ payloadWithProps({ tasks: [{ id: 6, title: "Buy coffee" }] }),
70
+ MODULE_REGISTRY,
71
+ );
72
+
73
+ expect(Array.isArray(tree.props.tasks)).toBe(true);
74
+ expect(tree.props.tasks).toEqual([{ id: 6, title: "Buy coffee" }]);
75
+ });
76
+
77
+ it("a prop holding TWO items is unchanged", () => {
78
+ const tree = createFromFlightPayload(
79
+ payloadWithProps({ tasks: [{ id: 6 }, { id: 7 }] }),
80
+ MODULE_REGISTRY,
81
+ );
82
+
83
+ expect(tree.props.tasks).toEqual([{ id: 6 }, { id: 7 }]);
84
+ });
85
+
86
+ it("a prop holding an EMPTY array stays an empty array", () => {
87
+ const tree = createFromFlightPayload(
88
+ payloadWithProps({ tasks: [] }),
89
+ MODULE_REGISTRY,
90
+ );
91
+
92
+ expect(Array.isArray(tree.props.tasks)).toBe(true);
93
+ expect(tree.props.tasks).toEqual([]);
94
+ });
95
+
96
+ it("a one-element array NESTED inside an object prop keeps its arity", () => {
97
+ const tree = createFromFlightPayload(
98
+ payloadWithProps({ page: { rows: [{ id: 6 }], total: 1 } }),
99
+ MODULE_REGISTRY,
100
+ );
101
+
102
+ expect(Array.isArray(tree.props.page.rows)).toBe(true);
103
+ expect(tree.props.page.rows).toEqual([{ id: 6 }]);
104
+ });
105
+
106
+ it("a one-element array of PRIMITIVES keeps its arity", () => {
107
+ const tree = createFromFlightPayload(
108
+ payloadWithProps({ tags: ["$$ruby"] }),
109
+ MODULE_REGISTRY,
110
+ );
111
+
112
+ // Decoded, not merely carried: "$$ruby" on the wire is "$ruby" in the prop.
113
+ expect(tree.props.tags).toEqual(["$ruby"]);
114
+ });
115
+
116
+ it("the scaffold's own prop shape survives (issue #62 reproduction)", () => {
117
+ // `<PostList posts={rows} />` with exactly one record. The generated
118
+ // scaffold gates its table on `sortedRows.length > 0`, so an object here
119
+ // renders neither the table nor the empty state — a blank list, no error.
120
+ // The throw needs a specific sequence, because the sort controls live
121
+ // inside the table that did not render: search (which works, since results
122
+ // arrive as JSON), sort, then clear the search — that puts the object back
123
+ // through `[...rows]`.
124
+ const tree = createFromFlightPayload(
125
+ payloadWithProps({ posts: [{ id: 1, title: "First post" }] }),
126
+ MODULE_REGISTRY,
127
+ );
128
+
129
+ // The component itself, not just its props — a rebuild that resolved the
130
+ // `$L` reference to a plain "div" would keep every prop assertion green
131
+ // while never running PostList at all.
132
+ expect(tree.type).toBe(TaskList);
133
+
134
+ const { posts } = tree.props;
135
+ expect(Array.isArray(posts)).toBe(true);
136
+ expect(posts).toHaveLength(1);
137
+ expect(() => [...posts]).not.toThrow();
138
+ });
139
+
140
+ it("an array INSIDE an array keeps both dimensions", () => {
141
+ // `.flat()` in the walker would collapse this and still satisfy every
142
+ // one-dimensional assertion above.
143
+ const tree = createFromFlightPayload(
144
+ payloadWithProps({ grid: [[1, 2], [3]] }),
145
+ MODULE_REGISTRY,
146
+ );
147
+
148
+ expect(tree.props.grid).toEqual([[1, 2], [3]]);
149
+ });
150
+
151
+ it("a ONE-element outer array wrapping an array keeps both levels", () => {
152
+ // Distinct from the grid case above, whose outer array has two members: a
153
+ // collapse conditioned on `length === 1 && Array.isArray(items[0])` would
154
+ // pass that one and corrupt this one.
155
+ const tree = createFromFlightPayload(
156
+ payloadWithProps({ matrix: [[1, 2]] }),
157
+ MODULE_REGISTRY,
158
+ );
159
+
160
+ expect(tree.props.matrix).toEqual([[1, 2]]);
161
+ });
162
+
163
+ it("an `undefined` MEMBER is kept — Flight distinguishes it from absence", () => {
164
+ // `$undefined` is a wire sentinel (project-context §7), so `[undefined]` is
165
+ // a one-member array, not an empty one. A `.filter(v => v !== undefined)`
166
+ // in the walker would silently make it empty.
167
+ const tree = createFromFlightPayload(
168
+ payloadWithProps({ slots: ["$undefined"] }),
169
+ MODULE_REGISTRY,
170
+ );
171
+
172
+ expect(tree.props.slots).toHaveLength(1);
173
+ expect(tree.props.slots[0]).toBeUndefined();
174
+ });
175
+
176
+ it("falsy items survive — they are values, not absences", () => {
177
+ // `.filter(Boolean)` in the walker would drop these and still satisfy
178
+ // every length assertion that uses truthy items.
179
+ const tree = createFromFlightPayload(
180
+ payloadWithProps({ flags: [false, 0, null, "", 1] }),
181
+ MODULE_REGISTRY,
182
+ );
183
+
184
+ expect(tree.props.flags).toEqual([false, 0, null, "", 1]);
185
+ });
186
+ });
187
+
188
+ describe("Story 17.0a — children keep their arity too (contract, decided 2026-09-09)", () => {
189
+ // Decision: arity is preserved for `children` as well as for data props.
190
+ //
191
+ // The server collapses a lone child produced by TAG NESTING before the wire
192
+ // (`html_converter.rb:188`, Suspense at `:202`), so that path is unaffected.
193
+ // But `children` passed as an EXPLICIT PROP on a self-closing tag —
194
+ // `<Label children={["hi"]} />` — reaches the wire as an array, and Story
195
+ // 15.2's loud error does not cover it (`erb_preprocessor.rb:174` exempts
196
+ // self-closing tags). Before this story the client collapsed that to `"hi"`.
197
+ // It no longer does: you passed an array, you get an array.
198
+ //
199
+ // Taken deliberately while the library has no external adopters — the only
200
+ // moment where making the contract consistent costs nothing.
201
+
202
+ it("a single child NESTED IN A TAG arrives collapsed, because the server collapsed it", () => {
203
+ const tree = buildTree(
204
+ ["$", "div", null, { children: ["$", "em", null, { children: "hi" }] }],
205
+ EMPTY_ROWS,
206
+ MODULE_REGISTRY,
207
+ );
208
+
209
+ // Not an array: the server sent the element itself, and it rebuilds as one.
210
+ expect(Array.isArray(tree.props.children)).toBe(false);
211
+ expect(tree.props.children.type).toBe("em");
212
+ expect(tree.props.children.props.children).toBe("hi");
213
+ });
214
+
215
+ it("a single TEXT child nested in a tag arrives as the string", () => {
216
+ const tree = buildTree(
217
+ ["$", "div", null, { children: "hi" }],
218
+ EMPTY_ROWS,
219
+ MODULE_REGISTRY,
220
+ );
221
+
222
+ expect(tree.props.children).toBe("hi");
223
+ });
224
+
225
+ it("multiple children arrive as an array, each one rebuilt", () => {
226
+ const tree = buildTree(
227
+ ["$", "ul", null, {
228
+ children: [
229
+ ["$", "li", "a", { children: "one" }],
230
+ ["$", "li", "b", { children: "two" }],
231
+ ],
232
+ }],
233
+ EMPTY_ROWS,
234
+ MODULE_REGISTRY,
235
+ );
236
+
237
+ const kids = tree.props.children;
238
+ expect(Array.isArray(kids)).toBe(true);
239
+ expect(kids).toHaveLength(2);
240
+ // Content, not just arity — a rebuild that returned nulls would pass a
241
+ // length check and fail this one.
242
+ expect(kids.map((k) => k.type)).toEqual(["li", "li"]);
243
+ expect(kids.map((k) => k.key)).toEqual(["a", "b"]);
244
+ expect(kids.map((k) => k.props.children)).toEqual(["one", "two"]);
245
+ });
246
+
247
+ it("an EXPLICIT one-element children prop stays a one-element array", () => {
248
+ // The contract change. `<Label children={["hi"]} />` — verified against the
249
+ // Ruby pipeline to reach the wire as `{"children":["hi"]}`.
250
+ const tree = buildTree(
251
+ ["$", "span", null, { children: ["hi"] }],
252
+ EMPTY_ROWS,
253
+ MODULE_REGISTRY,
254
+ );
255
+
256
+ expect(tree.props.children).toEqual(["hi"]);
257
+ });
258
+
259
+ it("a one-element children array of ELEMENTS stays an array, rebuilt", () => {
260
+ const tree = buildTree(
261
+ ["$", "ul", null, { children: [["$", "li", "only", { children: "just one" }]] }],
262
+ EMPTY_ROWS,
263
+ MODULE_REGISTRY,
264
+ );
265
+
266
+ const kids = tree.props.children;
267
+ expect(Array.isArray(kids)).toBe(true);
268
+ expect(kids).toHaveLength(1);
269
+ expect(kids[0].type).toBe("li");
270
+ expect(kids[0].key).toBe("only");
271
+ expect(kids[0].props.children).toBe("just one");
272
+ });
273
+
274
+ it("an EMPTY children array stays an empty array", () => {
275
+ // AC3's n=0 for children. Distinct from "no children prop at all" below:
276
+ // this one is present and empty, and must not become visible content.
277
+ const tree = buildTree(
278
+ ["$", "ul", null, { children: [] }],
279
+ EMPTY_ROWS,
280
+ MODULE_REGISTRY,
281
+ );
282
+
283
+ expect(Array.isArray(tree.props.children)).toBe(true);
284
+ expect(tree.props.children).toHaveLength(0);
285
+ });
286
+
287
+ it("an element with no children prop has none after rebuilding", () => {
288
+ const tree = buildTree(["$", "br", null, {}], EMPTY_ROWS, MODULE_REGISTRY);
289
+
290
+ expect("children" in tree.props).toBe(false);
291
+ });
292
+ });
293
+
294
+ describe("Story 17.0a — the contract, driven by a gem-produced fixture", () => {
295
+ // The bytes were written by the Ruby serializer, not typed here.
296
+ //
297
+ // The two sides guarantee DIFFERENT things, and neither reddens for the
298
+ // other: `spec/ruact/flight/serializer_spec.rb` ("single-element array prop
299
+ // survives the wire (Story 17.0a)") asserts that what the serializer emits
300
+ // still equals this file, so a server-side change reddens THERE; this test
301
+ // asserts that these bytes rebuild into a one-element array, so a client-side
302
+ // change reddens HERE. What the pair buys is that the client is exercised
303
+ // against real server output rather than a literal someone typed — the
304
+ // Story 5.2 pattern. It does not buy simultaneous failure.
305
+ const FIXTURE = path.join(
306
+ import.meta.dirname,
307
+ "../../../spec/fixtures/flight/single_element_array_prop.txt",
308
+ );
309
+
310
+ it("the fixture the gem wrote rebuilds as a one-element array", () => {
311
+ const payload = fs.readFileSync(FIXTURE, "utf8");
312
+ const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
313
+
314
+ expect(tree.type).toBe(PostList);
315
+ expect(Array.isArray(tree.props.posts)).toBe(true);
316
+ // The title is "$$5 plan" ON THE WIRE (§7 prepends one "$"). Asserting the
317
+ // DECODED value means a walker that preserved arity but stopped rebuilding
318
+ // scalar members would fail here rather than pass.
319
+ expect(tree.props.posts).toEqual([{ id: 1, title: "$5 plan" }]);
320
+ });
321
+ });
@@ -9,6 +9,7 @@
9
9
  "version": "0.0.0-bundled",
10
10
  "license": "MIT",
11
11
  "devDependencies": {
12
+ "react": "^19.2.8",
12
13
  "typescript": "^5.5.0",
13
14
  "vitest": "^2.0.0"
14
15
  }
@@ -1133,6 +1134,16 @@
1133
1134
  "node": "^10 || ^12 || >=14"
1134
1135
  }
1135
1136
  },
1137
+ "node_modules/react": {
1138
+ "version": "19.2.8",
1139
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
1140
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
1141
+ "dev": true,
1142
+ "license": "MIT",
1143
+ "engines": {
1144
+ "node": ">=0.10.0"
1145
+ }
1146
+ },
1136
1147
  "node_modules/rollup": {
1137
1148
  "version": "4.60.3",
1138
1149
  "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
@@ -9,6 +9,7 @@
9
9
  "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.scaffold.json && tsc --noEmit -p tsconfig.scaffold-agnostic.json"
10
10
  },
11
11
  "devDependencies": {
12
+ "react": "^19.2.8",
12
13
  "typescript": "^5.5.0",
13
14
  "vitest": "^2.0.0"
14
15
  },
@@ -196,9 +196,20 @@ function _buildTree(value, rows, moduleRegistry) {
196
196
  return createElement(type, props);
197
197
  }
198
198
 
199
- // Plain array / fragment children
200
- const items = value.map((v) => _buildTree(v, rows, moduleRegistry));
201
- return items.length === 1 ? items[0] : items;
199
+ // Plain array fragment children OR a data array sitting in a prop.
200
+ //
201
+ // Arity is preserved. This walker has no positional information: it cannot
202
+ // tell a `children` array from a list prop, so any shape change made here
203
+ // is made to both. It used to collapse single-element arrays (`items[0]`
204
+ // when `length === 1`), which silently turned a one-row list prop into the
205
+ // row itself — issue #62.
206
+ //
207
+ // Nothing needs to compensate: the server already collapses a lone child
208
+ // before it reaches the wire (`html_converter.rb:188`, and the Suspense
209
+ // path at `:202`), and React renders an array of children fine. If a
210
+ // client-side collapse is ever needed, it belongs in `buildProps` under
211
+ // `key === "children"`, where the position IS known.
212
+ return value.map((v) => _buildTree(v, rows, moduleRegistry));
202
213
  }
203
214
 
204
215
  // --- Plain objects ---
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruact
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.10
4
+ version: 0.0.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Luiz Garcia
8
8
  autorequire:
9
- bindir: exe
9
+ bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-25 00:00:00.000000000 Z
11
+ date: 2026-09-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri
@@ -36,21 +36,12 @@ executables: []
36
36
  extensions: []
37
37
  extra_rdoc_files: []
38
38
  files:
39
- - ".codecov.yml"
40
- - ".github/workflows/ci.yml"
41
- - ".github/workflows/server-functions-bench.yml"
42
- - ".rubocop.yml"
43
- - ".rubocop_todo.yml"
44
39
  - CHANGELOG.md
40
+ - CONTRIBUTING.md
45
41
  - LICENSE.txt
46
42
  - README.md
47
43
  - RELEASING.md
48
- - Rakefile
49
44
  - SECURITY.md
50
- - bench/server_functions_dispatch_bench.rb
51
- - bench/server_functions_dispatch_bench.results.md
52
- - docs/internal/README.md
53
- - docs/internal/decisions/server-functions-api.md
54
45
  - lib/generators/ruact/install/install_generator.rb
55
46
  - lib/generators/ruact/install/templates/AGENTS.md.tt
56
47
  - lib/generators/ruact/install/templates/Procfile.dev.tt
@@ -97,6 +88,7 @@ files:
97
88
  - lib/ruact/html_converter.rb
98
89
  - lib/ruact/layout_source.rb
99
90
  - lib/ruact/manifest_resolver.rb
91
+ - lib/ruact/packaging.rb
100
92
  - lib/ruact/query.rb
101
93
  - lib/ruact/railtie.rb
102
94
  - lib/ruact/render_context.rb
@@ -139,106 +131,13 @@ files:
139
131
  - lib/tasks/benchmark.rake
140
132
  - lib/tasks/ruact.rake
141
133
  - sig/ruact.rbs
142
- - spec/benchmarks/baseline.json
143
- - spec/benchmarks/render_pipeline_benchmark_spec.rb
144
- - spec/fixtures/flight/README.md
145
- - spec/fixtures/flight/array.txt
146
- - spec/fixtures/flight/as_json_object.txt
147
- - spec/fixtures/flight/bigint.txt
148
- - spec/fixtures/flight/boolean_false.txt
149
- - spec/fixtures/flight/boolean_true.txt
150
- - spec/fixtures/flight/client_component_with_props.txt
151
- - spec/fixtures/flight/client_reference.txt
152
- - spec/fixtures/flight/hash.txt
153
- - spec/fixtures/flight/infinity.txt
154
- - spec/fixtures/flight/nan.txt
155
- - spec/fixtures/flight/negative_infinity.txt
156
- - spec/fixtures/flight/nil.txt
157
- - spec/fixtures/flight/number_float.txt
158
- - spec/fixtures/flight/number_integer.txt
159
- - spec/fixtures/flight/react_element_no_props.txt
160
- - spec/fixtures/flight/redirect_row.txt
161
- - spec/fixtures/flight/serializable_object.txt
162
- - spec/fixtures/flight/string_basic.txt
163
- - spec/fixtures/flight/string_dollar_escape.txt
164
- - spec/fixtures/flight/undefined.txt
165
- - spec/fixtures/readme/children-error.html.erb
166
- - spec/fixtures/readme/children-error.txt
167
- - spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb
168
- - spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb
169
- - spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb
170
- - spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb
171
- - spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb
172
- - spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb
173
- - spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb
174
- - spec/fixtures/story_7_9_views/layouts/bare_host.html.erb
175
- - spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb
176
- - spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb
177
- - spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb
178
- - spec/readme_demo_message_spec.rb
179
- - spec/readme_spec.rb
180
- - spec/ruact/client_manifest_spec.rb
181
- - spec/ruact/component_contract_spec.rb
182
- - spec/ruact/configuration_spec.rb
183
- - spec/ruact/controller_request_spec.rb
184
- - spec/ruact/controller_spec.rb
185
- - spec/ruact/doctor_spec.rb
186
- - spec/ruact/erb_preprocessor_hook_spec.rb
187
- - spec/ruact/erb_preprocessor_spec.rb
188
- - spec/ruact/errors_spec.rb
189
- - spec/ruact/flight/renderer_spec.rb
190
- - spec/ruact/flight/serializer_spec.rb
191
- - spec/ruact/html_converter_spec.rb
192
- - spec/ruact/install_generator_spec.rb
193
- - spec/ruact/layout_source_spec.rb
194
- - spec/ruact/manifest_resolver_spec.rb
195
- - spec/ruact/query_request_spec.rb
196
- - spec/ruact/query_spec.rb
197
- - spec/ruact/railtie_spec.rb
198
- - spec/ruact/render_context_spec.rb
199
- - spec/ruact/render_pipeline_concurrency_spec.rb
200
- - spec/ruact/render_pipeline_spec.rb
201
- - spec/ruact/scaffold_generator_spec.rb
202
- - spec/ruact/serializable_spec.rb
203
- - spec/ruact/server_bucket_request_spec.rb
204
- - spec/ruact/server_function_name_spec.rb
205
- - spec/ruact/server_functions/backtrace_cleaner_spec.rb
206
- - spec/ruact/server_functions/bucket_two_payload_spec.rb
207
- - spec/ruact/server_functions/codegen_spec.rb
208
- - spec/ruact/server_functions/error_payload_spec.rb
209
- - spec/ruact/server_functions/error_suggestion_spec.rb
210
- - spec/ruact/server_functions/introspection_spec.rb
211
- - spec/ruact/server_functions/name_bridge_spec.rb
212
- - spec/ruact/server_functions/query_context_spec.rb
213
- - spec/ruact/server_functions/query_source_spec.rb
214
- - spec/ruact/server_functions/railtie_integration_spec.rb
215
- - spec/ruact/server_functions/rake_spec.rb
216
- - spec/ruact/server_functions/route_source_spec.rb
217
- - spec/ruact/server_functions/snapshot_spec.rb
218
- - spec/ruact/server_functions/snapshot_writer_spec.rb
219
- - spec/ruact/server_rescue_request_spec.rb
220
- - spec/ruact/server_spec.rb
221
- - spec/ruact/server_upload_request_spec.rb
222
- - spec/ruact/signed_references_spec.rb
223
- - spec/ruact/string_distance_spec.rb
224
- - spec/ruact/tasks_json_introspection_spec.rb
225
- - spec/ruact/testing/have_ruact_component_spec.rb
226
- - spec/ruact/testing/no_production_load_spec.rb
227
- - spec/ruact/validation_errors_spec.rb
228
- - spec/ruact/view_helper_spec.rb
229
- - spec/spec_helper.rb
230
- - spec/support/fixtures/pixel.png
231
- - spec/support/flight_wire_parser.rb
232
- - spec/support/flight_wire_parser_spec.rb
233
- - spec/support/matchers/flight_fixture_matcher.rb
234
- - spec/support/matchers/flight_fixture_matcher_spec.rb
235
- - spec/support/rails_stub.rb
236
134
  - vendor/javascript/ruact-server-functions-runtime/index.d.ts
237
135
  - vendor/javascript/ruact-server-functions-runtime/index.js
238
136
  - vendor/javascript/ruact-server-functions-runtime/index.test.mjs
239
137
  - vendor/javascript/ruact-server-functions-runtime/package.json
240
138
  - vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs
241
139
  - vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs
140
+ - vendor/javascript/vite-plugin-ruact/flight-client.test.mjs
242
141
  - vendor/javascript/vite-plugin-ruact/index.js
243
142
  - vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs
244
143
  - vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs
data/.codecov.yml DELETED
@@ -1,31 +0,0 @@
1
- # Codecov configuration for the ruact gem.
2
- # Story 6.7 — coverage is informativo (not a CI gate); Codecov reports status
3
- # but never fails a PR. Diff coverage targets (90% line / 80% branch) are
4
- # project DoD, surfaced via the per-PR comment for visibility.
5
-
6
- coverage:
7
- status:
8
- project:
9
- default:
10
- informational: true
11
- target: auto
12
- threshold: 1%
13
- patch:
14
- default:
15
- informational: true
16
- target: 90%
17
- threshold: 0%
18
-
19
- comment:
20
- layout: "diff, files"
21
- behavior: default
22
- require_changes: false
23
-
24
- flags:
25
- gem:
26
- # Paths are relative to the gem repo root (where this CI runs and uploads
27
- # from). lcov.info emits source paths like `./lib/ruact.rb`; Codecov
28
- # normalizes the leading `./` so a `lib/` prefix matches.
29
- paths:
30
- - lib/
31
- carryforward: false