ruact 0.0.11 → 0.0.13

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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +147 -48
  3. data/CONTRIBUTING.md +233 -0
  4. data/README.md +18 -8
  5. data/RELEASING.md +125 -139
  6. data/lib/generators/ruact/install/install_generator.rb +305 -126
  7. data/lib/generators/ruact/install/templates/AGENTS.md.tt +14 -13
  8. data/lib/generators/ruact/install/templates/initializer.rb.tt +29 -7
  9. data/lib/generators/ruact/install/templates/tsconfig.json.tt +3 -0
  10. data/lib/generators/ruact/layout/layout_generator.rb +52 -0
  11. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +5 -3
  12. data/lib/ruact/configuration.rb +65 -13
  13. data/lib/ruact/controller/document_rendering.rb +72 -16
  14. data/lib/ruact/controller/page_rendering.rb +134 -0
  15. data/lib/ruact/controller/pages.rb +116 -0
  16. data/lib/ruact/controller.rb +78 -11
  17. data/lib/ruact/doctor.rb +233 -25
  18. data/lib/ruact/layout_source.rb +29 -7
  19. data/lib/ruact/navigation_boundary.rb +240 -0
  20. data/lib/ruact/packaging.rb +68 -0
  21. data/lib/ruact/railtie.rb +30 -0
  22. data/lib/ruact/server.rb +10 -1
  23. data/lib/ruact/version.rb +1 -1
  24. data/lib/ruact/view_helper.rb +158 -1
  25. data/lib/ruact/views/layouts/ruact.html.erb +32 -0
  26. data/lib/ruact.rb +29 -0
  27. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +321 -0
  28. data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
  29. data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
  30. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +433 -0
  31. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
  32. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +170 -7
  33. metadata +12 -107
  34. data/.codecov.yml +0 -31
  35. data/.github/workflows/ci.yml +0 -284
  36. data/.github/workflows/server-functions-bench.yml +0 -54
  37. data/.rubocop.yml +0 -107
  38. data/.rubocop_todo.yml +0 -63
  39. data/Rakefile +0 -10
  40. data/bench/server_functions_dispatch_bench.rb +0 -276
  41. data/bench/server_functions_dispatch_bench.results.md +0 -150
  42. data/docs/internal/README.md +0 -9
  43. data/docs/internal/decisions/server-functions-api.md +0 -2236
  44. data/spec/benchmarks/baseline.json +0 -12
  45. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -109
  46. data/spec/fixtures/flight/README.md +0 -136
  47. data/spec/fixtures/flight/array.txt +0 -1
  48. data/spec/fixtures/flight/as_json_object.txt +0 -2
  49. data/spec/fixtures/flight/bigint.txt +0 -1
  50. data/spec/fixtures/flight/boolean_false.txt +0 -1
  51. data/spec/fixtures/flight/boolean_true.txt +0 -1
  52. data/spec/fixtures/flight/client_component_with_props.txt +0 -2
  53. data/spec/fixtures/flight/client_reference.txt +0 -2
  54. data/spec/fixtures/flight/hash.txt +0 -1
  55. data/spec/fixtures/flight/infinity.txt +0 -1
  56. data/spec/fixtures/flight/nan.txt +0 -1
  57. data/spec/fixtures/flight/negative_infinity.txt +0 -1
  58. data/spec/fixtures/flight/nil.txt +0 -1
  59. data/spec/fixtures/flight/number_float.txt +0 -1
  60. data/spec/fixtures/flight/number_integer.txt +0 -1
  61. data/spec/fixtures/flight/react_element_no_props.txt +0 -1
  62. data/spec/fixtures/flight/redirect_row.txt +0 -1
  63. data/spec/fixtures/flight/serializable_object.txt +0 -2
  64. data/spec/fixtures/flight/string_basic.txt +0 -1
  65. data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
  66. data/spec/fixtures/flight/undefined.txt +0 -1
  67. data/spec/fixtures/readme/children-error.html.erb +0 -3
  68. data/spec/fixtures/readme/children-error.txt +0 -1
  69. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
  70. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
  71. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
  72. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
  73. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
  74. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
  75. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
  76. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
  77. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
  78. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
  79. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
  80. data/spec/readme_demo_message_spec.rb +0 -67
  81. data/spec/readme_spec.rb +0 -282
  82. data/spec/ruact/client_manifest_spec.rb +0 -270
  83. data/spec/ruact/component_contract_spec.rb +0 -119
  84. data/spec/ruact/configuration_spec.rb +0 -518
  85. data/spec/ruact/controller_request_spec.rb +0 -671
  86. data/spec/ruact/controller_spec.rb +0 -343
  87. data/spec/ruact/doctor_spec.rb +0 -769
  88. data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
  89. data/spec/ruact/erb_preprocessor_spec.rb +0 -361
  90. data/spec/ruact/errors_spec.rb +0 -93
  91. data/spec/ruact/flight/renderer_spec.rb +0 -133
  92. data/spec/ruact/flight/serializer_spec.rb +0 -494
  93. data/spec/ruact/html_converter_spec.rb +0 -375
  94. data/spec/ruact/install_generator_spec.rb +0 -1549
  95. data/spec/ruact/layout_source_spec.rb +0 -108
  96. data/spec/ruact/manifest_resolver_spec.rb +0 -174
  97. data/spec/ruact/query_request_spec.rb +0 -706
  98. data/spec/ruact/query_spec.rb +0 -105
  99. data/spec/ruact/railtie_spec.rb +0 -155
  100. data/spec/ruact/render_context_spec.rb +0 -58
  101. data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
  102. data/spec/ruact/render_pipeline_spec.rb +0 -928
  103. data/spec/ruact/scaffold_generator_spec.rb +0 -1849
  104. data/spec/ruact/serializable_spec.rb +0 -179
  105. data/spec/ruact/server_bucket_request_spec.rb +0 -785
  106. data/spec/ruact/server_function_name_spec.rb +0 -53
  107. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
  108. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
  109. data/spec/ruact/server_functions/codegen_spec.rb +0 -397
  110. data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
  111. data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
  112. data/spec/ruact/server_functions/introspection_spec.rb +0 -135
  113. data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
  114. data/spec/ruact/server_functions/query_context_spec.rb +0 -72
  115. data/spec/ruact/server_functions/query_source_spec.rb +0 -193
  116. data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
  117. data/spec/ruact/server_functions/rake_spec.rb +0 -86
  118. data/spec/ruact/server_functions/route_source_spec.rb +0 -202
  119. data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
  120. data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
  121. data/spec/ruact/server_rescue_request_spec.rb +0 -416
  122. data/spec/ruact/server_spec.rb +0 -179
  123. data/spec/ruact/server_upload_request_spec.rb +0 -311
  124. data/spec/ruact/signed_references_spec.rb +0 -164
  125. data/spec/ruact/string_distance_spec.rb +0 -38
  126. data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
  127. data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
  128. data/spec/ruact/testing/no_production_load_spec.rb +0 -41
  129. data/spec/ruact/validation_errors_spec.rb +0 -116
  130. data/spec/ruact/view_helper_spec.rb +0 -131
  131. data/spec/spec_helper.rb +0 -77
  132. data/spec/support/fixtures/pixel.png +0 -0
  133. data/spec/support/flight_wire_parser.rb +0 -21
  134. data/spec/support/flight_wire_parser_spec.rb +0 -93
  135. data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
  136. data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
  137. data/spec/support/rails_stub.rb +0 -115
@@ -0,0 +1,433 @@
1
+ // Story 17.0f — the navigation boundary, from the router's side.
2
+ //
3
+ // ruact-router.js had no tests at all. It runs in a browser; this file runs in
4
+ // vitest's node environment with the smallest fakes of `document`, `window`,
5
+ // `location`, `history` and `HTMLFormElement` the router actually touches — no
6
+ // jsdom, which this package does not depend on. `fetch` is stubbed per test.
7
+ //
8
+ // What is pinned: a response the server marks `Ruact-Boundary: native` is handed
9
+ // to the browser (a full load, or a NATIVE form submit — the action never ran,
10
+ // so resubmitting is safe); a non-Flight response that escaped the server's
11
+ // classifier is never a silent no-op; and a Flight response still renders.
12
+
13
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
14
+ import { setupRouter, teardownRouter } from "./runtime/ruact-router.js";
15
+
16
+ const ORIGIN = "http://localhost:3000";
17
+
18
+ function fakeLocation(path) {
19
+ let url = new URL(path, ORIGIN);
20
+ return {
21
+ get href() { return url.href; },
22
+ get origin() { return url.origin; },
23
+ get pathname() { return url.pathname; },
24
+ get search() { return url.search; },
25
+ get hash() { return url.hash; },
26
+ assign: vi.fn(),
27
+ replace: vi.fn(),
28
+ _set(next) { url = new URL(next, ORIGIN); },
29
+ };
30
+ }
31
+
32
+ function installDom(path = "/products/1") {
33
+ const listeners = { document: {}, window: {} };
34
+ const on = (bucket) => (type, fn) => { (listeners[bucket][type] ||= []).push(fn); };
35
+ const off = (bucket) => (type, fn) => {
36
+ listeners[bucket][type] = (listeners[bucket][type] || []).filter((f) => f !== fn);
37
+ };
38
+
39
+ const metas = {};
40
+ globalThis.document = {
41
+ addEventListener: on("document"),
42
+ removeEventListener: off("document"),
43
+ querySelector: (selector) => {
44
+ const name = selector.match(/meta\[name="([^"]+)"\]/)?.[1];
45
+ return name && name in metas ? { content: metas[name] } : null;
46
+ },
47
+ createElement: (tag) => {
48
+ const el = { tagName: tag.toUpperCase(), type: "", name: "", value: "", parent: null };
49
+ el.remove = () => { if (el.parent) el.parent.children = el.parent.children.filter((c) => c !== el); };
50
+ return el;
51
+ },
52
+ };
53
+ globalThis.window = {
54
+ addEventListener: on("window"),
55
+ removeEventListener: off("window"),
56
+ scrollTo: vi.fn(),
57
+ };
58
+ globalThis.location = fakeLocation(path);
59
+ globalThis.history = { pushState: vi.fn((_s, _t, next) => globalThis.location._set(next)) };
60
+
61
+ class FakeForm {
62
+ constructor(attrs) {
63
+ this.tagName = "FORM";
64
+ this.attrs = { ...attrs };
65
+ this.children = [];
66
+ }
67
+ getAttribute(name) { return name in this.attrs ? this.attrs[name] : null; }
68
+ hasAttribute(name) { return name in this.attrs; }
69
+ setAttribute(name, value) { this.attrs[name] = value; }
70
+ removeAttribute(name) { delete this.attrs[name]; }
71
+ appendChild(node) { node.parent = this; this.children.push(node); }
72
+ querySelector(selector) {
73
+ const name = selector.match(/\[name="([^"]+)"\]/)?.[1];
74
+ return this.children.find((c) => c.name === name) ?? null;
75
+ }
76
+ get action() { return new URL(this.attrs.action ?? globalThis.location.href, ORIGIN).href; }
77
+ submit() {}
78
+ }
79
+ globalThis.HTMLFormElement = FakeForm;
80
+ globalThis.FormData = class { constructor() { this.entries = []; } forEach() {} };
81
+
82
+ return { listeners, FakeForm, metas };
83
+ }
84
+
85
+ function flightBody(text) {
86
+ return new ReadableStream({
87
+ start(controller) {
88
+ controller.enqueue(new TextEncoder().encode(text));
89
+ controller.close();
90
+ },
91
+ });
92
+ }
93
+
94
+ function respond({ status = 200, contentType = "text/x-component", boundary = null, body = "", url = "" } = {}) {
95
+ const headers = new Headers({ "content-type": contentType });
96
+ if (boundary) headers.set("ruact-boundary", boundary);
97
+ return { ok: status >= 200 && status < 300, status, statusText: String(status), url, headers, body: flightBody(body) };
98
+ }
99
+
100
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
101
+
102
+ async function settle() {
103
+ for (let i = 0; i < 5; i += 1) await flush();
104
+ }
105
+
106
+ function anchor(href) {
107
+ const el = {
108
+ getAttribute: (name) => (name === "href" ? href : null),
109
+ hasAttribute: () => false,
110
+ target: "",
111
+ };
112
+ el.closest = () => el;
113
+ return el;
114
+ }
115
+
116
+ function click(listeners, href) {
117
+ const event = {
118
+ defaultPrevented: false, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, button: 0,
119
+ target: anchor(href),
120
+ preventDefault() { this.defaultPrevented = true; },
121
+ };
122
+ listeners.document.click.forEach((fn) => fn(event));
123
+ return event;
124
+ }
125
+
126
+ function submit(listeners, form, submitter = null) {
127
+ const event = { defaultPrevented: false, target: form, submitter, preventDefault() { this.defaultPrevented = true; } };
128
+ listeners.document.submit.forEach((fn) => fn(event));
129
+ return event;
130
+ }
131
+
132
+ describe("ruact-router — the navigation boundary (Story 17.0f)", () => {
133
+ let dom;
134
+ let onNavigate;
135
+ let onError;
136
+
137
+ beforeEach(() => {
138
+ dom = installDom();
139
+ onNavigate = vi.fn();
140
+ onError = vi.fn();
141
+ globalThis.fetch = vi.fn();
142
+ vi.spyOn(console, "error").mockImplementation(() => {});
143
+ setupRouter({ onNavigate, moduleRegistry: {}, onError });
144
+ });
145
+
146
+ afterEach(() => {
147
+ teardownRouter();
148
+ vi.restoreAllMocks();
149
+ });
150
+
151
+ describe("a link the server marks Ruact-Boundary: native", () => {
152
+ it("is handed to the browser as a full load — no render, no pushState", async () => {
153
+ fetch.mockResolvedValue(respond({ contentType: "text/plain", boundary: "native", url: `${ORIGIN}/people/1` }));
154
+
155
+ click(dom.listeners, "/people/1");
156
+ await settle();
157
+
158
+ expect(location.assign).toHaveBeenCalledWith(`${ORIGIN}/people/1`);
159
+ expect(onNavigate).not.toHaveBeenCalled();
160
+ expect(history.pushState).not.toHaveBeenCalled();
161
+ expect(onError).not.toHaveBeenCalled();
162
+ });
163
+
164
+ // Back/forward: `assign` would push a NEW entry in the middle of the history
165
+ // the user is walking through.
166
+ it("uses location.replace when it came from popstate", async () => {
167
+ fetch.mockResolvedValue(respond({ contentType: "text/plain", boundary: "native", url: `${ORIGIN}/people/1` }));
168
+ location._set("/people/1");
169
+
170
+ dom.listeners.window.popstate.forEach((fn) => fn({}));
171
+ await settle();
172
+
173
+ expect(location.replace).toHaveBeenCalledWith(`${ORIGIN}/people/1`);
174
+ expect(location.assign).not.toHaveBeenCalled();
175
+ });
176
+ });
177
+
178
+ describe("a non-Flight answer that escaped the server's classifier", () => {
179
+ // The 2026-09-12 spike's S2: a Rails controller answers 200 text/html, the
180
+ // line parser returns null for every HTML line, nothing renders, the URL does
181
+ // not change, and nothing reports an error. A dead click.
182
+ it("on a GET, becomes a full load instead of a dead click", async () => {
183
+ fetch.mockResolvedValue(respond({ contentType: "text/html; charset=utf-8", body: "<h1>person</h1>\n", url: `${ORIGIN}/people/1` }));
184
+
185
+ click(dom.listeners, "/people/1");
186
+ await settle();
187
+
188
+ expect(location.assign).toHaveBeenCalledWith(`${ORIGIN}/people/1`);
189
+ expect(onNavigate).not.toHaveBeenCalled();
190
+ });
191
+
192
+ // The action already ran: resubmitting would run it twice. Say so instead.
193
+ it("on a non-GET form, reports an error naming the form and the fix — never silent, never resubmitted", async () => {
194
+ const form = new dom.FakeForm({ action: "/people", method: "post" });
195
+ const nativeSubmit = vi.spyOn(HTMLFormElement.prototype, "submit");
196
+ fetch.mockResolvedValue(respond({ status: 422, contentType: "text/html", body: "<p>invalid</p>" }));
197
+
198
+ submit(dom.listeners, form);
199
+ await settle();
200
+
201
+ expect(onError).toHaveBeenCalledTimes(1);
202
+ const message = onError.mock.calls[0][0].message;
203
+ expect(message).toContain("POST /people");
204
+ expect(message).toContain("422");
205
+ expect(message).toContain('data-ruact="false"');
206
+ expect(nativeSubmit).not.toHaveBeenCalled();
207
+ expect(location.assign).not.toHaveBeenCalled();
208
+ });
209
+ });
210
+
211
+ describe("a form the server marks Ruact-Boundary: native", () => {
212
+ it("is submitted NATIVELY — the action never ran, so this is its one run", async () => {
213
+ const form = new dom.FakeForm({ action: "/people", method: "post" });
214
+ const nativeSubmit = vi.spyOn(HTMLFormElement.prototype, "submit");
215
+ fetch.mockResolvedValue(respond({ contentType: "text/plain", boundary: "native" }));
216
+
217
+ submit(dom.listeners, form);
218
+ await settle();
219
+
220
+ expect(nativeSubmit).toHaveBeenCalledTimes(1);
221
+ expect(nativeSubmit.mock.contexts[0]).toBe(form);
222
+ expect(fetch).toHaveBeenCalledTimes(1);
223
+ expect(onError).not.toHaveBeenCalled();
224
+ });
225
+
226
+ // A native submit loses the submitter unless it is carried over: the
227
+ // browser only includes `name=value` of the button that was clicked.
228
+ it("carries the submitter's name/value and overrides DURING the submit, and takes them back after", async () => {
229
+ const form = new dom.FakeForm({ action: "/people", method: "post" });
230
+ const submitter = {
231
+ name: "op", value: "archive",
232
+ hasAttribute: (n) => ["formAction", "formMethod", "formaction", "formmethod", "formenctype"].includes(n),
233
+ getAttribute: (n) => ({
234
+ formAction: "/people/archive", formaction: "/people/archive",
235
+ formMethod: "post", formmethod: "post", formenctype: "multipart/form-data",
236
+ })[n] ?? null,
237
+ };
238
+ let during;
239
+ vi.spyOn(HTMLFormElement.prototype, "submit").mockImplementation(function () {
240
+ during = { action: this.getAttribute("action"), enctype: this.getAttribute("enctype"), fields: this.children.map((c) => [c.name, c.value]) };
241
+ });
242
+ fetch.mockResolvedValue(respond({ contentType: "text/plain", boundary: "native" }));
243
+
244
+ submit(dom.listeners, form, submitter);
245
+ await settle();
246
+
247
+ expect(during).toEqual({ action: "/people/archive", enctype: "multipart/form-data", fields: [["op", "archive"]] });
248
+ // Taken back: a page that survives the submit keeps its form as it was.
249
+ expect(form.getAttribute("action")).toBe("/people");
250
+ expect(form.hasAttribute("enctype")).toBe(false);
251
+ expect(form.children).toEqual([]);
252
+ });
253
+
254
+ // Review round 1 — a form a React component rendered has no token field;
255
+ // the router's fetch sent it as a header, a native submit cannot.
256
+ it("adds the CSRF token from the meta tags when the form has no token field", async () => {
257
+ dom.metas["csrf-param"] = "authenticity_token";
258
+ dom.metas["csrf-token"] = "tok-123";
259
+ const form = new dom.FakeForm({ action: "/people", method: "post" });
260
+ let fields;
261
+ vi.spyOn(HTMLFormElement.prototype, "submit").mockImplementation(function () {
262
+ fields = this.children.map((c) => [c.name, c.value]);
263
+ });
264
+ fetch.mockResolvedValue(respond({ contentType: "text/plain", boundary: "native" }));
265
+
266
+ submit(dom.listeners, form);
267
+ await settle();
268
+
269
+ expect(fields).toEqual([["authenticity_token", "tok-123"]]);
270
+ });
271
+
272
+ // Review round 1 — a native answer at the END of a redirect the fetch
273
+ // followed means the action already ran. Resubmitting would run it twice.
274
+ it("loads the redirect target instead of resubmitting when the fetch was redirected", async () => {
275
+ const form = new dom.FakeForm({ action: "/people", method: "post" });
276
+ const nativeSubmit = vi.spyOn(HTMLFormElement.prototype, "submit");
277
+ fetch.mockResolvedValue({ ...respond({ contentType: "text/plain", boundary: "native", url: `${ORIGIN}/people/3` }), redirected: true });
278
+
279
+ submit(dom.listeners, form);
280
+ await settle();
281
+
282
+ expect(nativeSubmit).not.toHaveBeenCalled();
283
+ expect(location.assign).toHaveBeenCalledWith(`${ORIGIN}/people/3`);
284
+ });
285
+ });
286
+
287
+ describe("review round 1 — errors stay errors", () => {
288
+ // Review round 2 — the default app passes no onError: an error page kept
289
+ // on the error path is a console line and a dead click. Load it, so the
290
+ // user sees it; repeating a GET is harmless.
291
+ it("loads a GET error page (500 HTML) in full so the user sees it", async () => {
292
+ fetch.mockResolvedValue(respond({ status: 500, contentType: "text/html", body: "<h1>oops</h1>", url: `${ORIGIN}/products/9` }));
293
+
294
+ click(dom.listeners, "/products/9");
295
+ await settle();
296
+
297
+ expect(location.assign).toHaveBeenCalledWith(`${ORIGIN}/products/9`);
298
+ });
299
+
300
+ it("rejects revalidate() when the server says the page is not ruact's", async () => {
301
+ fetch.mockResolvedValue(respond({ contentType: "text/plain", boundary: "native" }));
302
+
303
+ await expect(globalThis.__ruact_revalidate()).rejects.toThrow("is not a ruact page");
304
+ expect(location.assign).not.toHaveBeenCalled();
305
+ });
306
+
307
+ it("rejects revalidate() on a non-Flight answer instead of reloading", async () => {
308
+ fetch.mockResolvedValue(respond({ contentType: "text/html", body: "<h1>html</h1>" }));
309
+
310
+ await expect(globalThis.__ruact_revalidate()).rejects.toThrow("did not answer with a ruact response");
311
+ expect(location.assign).not.toHaveBeenCalled();
312
+ });
313
+
314
+ // A 403 is a CSRF rejection: the action did NOT run, and telling the
315
+ // developer it did (and to add data-ruact="false") points the wrong way.
316
+ it("does not call a 403 on a form 'not a ruact page'", async () => {
317
+ const form = new dom.FakeForm({ action: "/people", method: "post" });
318
+ fetch.mockResolvedValue(respond({ status: 403, contentType: "text/html", body: "forbidden" }));
319
+
320
+ submit(dom.listeners, form);
321
+ await settle();
322
+
323
+ expect(onError.mock.calls[0][0].message).not.toContain("data-ruact");
324
+ expect(onError.mock.calls[0][0].message).toContain("403");
325
+ });
326
+
327
+ // Fetch drops the #fragment from response.url.
328
+ it("keeps the link's #fragment on a full load", async () => {
329
+ fetch.mockResolvedValue(respond({ contentType: "text/plain", boundary: "native", url: `${ORIGIN}/docs/guide` }));
330
+
331
+ click(dom.listeners, "/docs/guide#install");
332
+ await settle();
333
+
334
+ expect(location.assign).toHaveBeenCalledWith(`${ORIGIN}/docs/guide#install`);
335
+ });
336
+ });
337
+
338
+ // Story 17.0i — `render :new, status: :unprocessable_entity` on a ruact page
339
+ // answers Flight with a 422: the validation errors are the page's content,
340
+ // not a transport failure.
341
+ describe("a Flight answer with a 422", () => {
342
+ const tree = '0:["$","p",null,{"children":"Title can\'t be blank"}]\n';
343
+
344
+ it("renders a form's re-render and keeps the form's URL — the POST URL is not replayable", async () => {
345
+ const form = new dom.FakeForm({ action: "/posts", method: "post" });
346
+ fetch.mockResolvedValue(respond({ status: 422, body: tree, url: `${ORIGIN}/posts` }));
347
+ globalThis.location._set("/posts/new");
348
+
349
+ submit(dom.listeners, form);
350
+ await settle();
351
+
352
+ expect(onNavigate).toHaveBeenCalledTimes(1);
353
+ expect(onError).not.toHaveBeenCalled();
354
+ expect(history.pushState).not.toHaveBeenCalled();
355
+ expect(location.pathname).toBe("/posts/new");
356
+ });
357
+
358
+ it("renders a navigation's 422 too", async () => {
359
+ fetch.mockResolvedValue(respond({ status: 422, body: tree, url: `${ORIGIN}/posts/new` }));
360
+
361
+ click(dom.listeners, "/posts/new");
362
+ await settle();
363
+
364
+ expect(onNavigate).toHaveBeenCalledTimes(1);
365
+ expect(onError).not.toHaveBeenCalled();
366
+ });
367
+
368
+ it("resolves revalidate() — a 422 is content, not a failure", async () => {
369
+ fetch.mockResolvedValue(respond({ status: 422, body: tree }));
370
+
371
+ await expect(globalThis.__ruact_revalidate()).resolves.not.toThrow();
372
+ expect(onNavigate).toHaveBeenCalledTimes(1);
373
+ });
374
+
375
+ // Review R1 — only a FLIGHT 422 is a page. Rails' own HTML 422 error page
376
+ // (RecordInvalid) on a revalidate is a failure to report, as before.
377
+ it("still rejects revalidate() on a 422 that is not Flight", async () => {
378
+ fetch.mockResolvedValue(respond({ status: 422, contentType: "text/html", body: "<h1>Unprocessable</h1>" }));
379
+
380
+ await expect(globalThis.__ruact_revalidate()).rejects.toThrow();
381
+ expect(onNavigate).not.toHaveBeenCalled();
382
+ });
383
+
384
+ // Review R1 — a POST the fetch followed through a redirect ended on a GET:
385
+ // that URL can be asked for again, and it is the page being shown.
386
+ it("pushes the URL a form's redirect ended on", async () => {
387
+ const form = new dom.FakeForm({ action: "/posts", method: "post" });
388
+ const response = respond({ status: 422, body: tree, url: `${ORIGIN}/drafts/9/edit` });
389
+ response.redirected = true;
390
+ fetch.mockResolvedValue(response);
391
+ globalThis.location._set("/posts/new");
392
+
393
+ submit(dom.listeners, form);
394
+ await settle();
395
+
396
+ expect(onNavigate).toHaveBeenCalledTimes(1);
397
+ expect(history.pushState).toHaveBeenCalledWith(null, "", "/drafts/9/edit");
398
+ });
399
+
400
+ it("still rejects revalidate() on any other failing Flight status", async () => {
401
+ for (const status of [404, 500]) {
402
+ fetch.mockResolvedValue(respond({ status, body: tree }));
403
+ await expect(globalThis.__ruact_revalidate()).rejects.toThrow(`Request failed: ${status}`);
404
+ }
405
+ expect(onNavigate).not.toHaveBeenCalled();
406
+ });
407
+
408
+ it("keeps every other non-2xx Flight answer on the error path", async () => {
409
+ const form = new dom.FakeForm({ action: "/posts", method: "post" });
410
+ fetch.mockResolvedValue(respond({ status: 500, body: tree }));
411
+
412
+ submit(dom.listeners, form);
413
+ await settle();
414
+
415
+ expect(onNavigate).not.toHaveBeenCalled();
416
+ expect(onError).toHaveBeenCalledTimes(1);
417
+ expect(onError.mock.calls[0][0].message).toContain("Request failed: 500");
418
+ });
419
+ });
420
+
421
+ describe("a Flight answer", () => {
422
+ it("still renders in place, as before", async () => {
423
+ fetch.mockResolvedValue(respond({ body: '0:["$","h1",null,{"children":"hi"}]\n', url: `${ORIGIN}/products/2` }));
424
+
425
+ click(dom.listeners, "/products/2");
426
+ await settle();
427
+
428
+ expect(onNavigate).toHaveBeenCalledTimes(1);
429
+ expect(history.pushState).toHaveBeenCalled();
430
+ expect(location.assign).not.toHaveBeenCalled();
431
+ });
432
+ });
433
+ });
@@ -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 ---