ruact 0.0.12 → 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.
@@ -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
+ });
@@ -275,6 +275,101 @@ function _buildFormData(form, submitter) {
275
275
  }
276
276
  }
277
277
 
278
+ // ---------------------------------------------------------------------------
279
+ // Story 17.0f — the navigation boundary
280
+ // ---------------------------------------------------------------------------
281
+
282
+ function _isNativeBoundary(response) {
283
+ return response.headers?.get("ruact-boundary") === "native";
284
+ }
285
+
286
+ function _isFlight(response) {
287
+ return (response.headers?.get("content-type") || "").includes("text/x-component");
288
+ }
289
+
290
+ // A full document load of the final URL (after any redirect the fetch
291
+ // followed). `replace` for back/forward, so no entry is pushed mid-history.
292
+ //
293
+ // `response.url` never carries the #fragment (Fetch strips it), so the one
294
+ // the link asked for is put back.
295
+ function _fullLoad(response, targetUrl, { replace = false } = {}) {
296
+ const requested = new URL(targetUrl, location.href);
297
+ const destination = new URL(response.url || requested.href);
298
+ if (!destination.hash && requested.hash) destination.hash = requested.hash;
299
+ if (replace) location.replace(destination.href);
300
+ else location.assign(destination.href);
301
+ }
302
+
303
+ function _escapedFormMessage(method, targetUrl, response) {
304
+ const path = new URL(targetUrl, location.href).pathname;
305
+ const type = response.headers?.get("content-type") || "no content type";
306
+ return `[ruact] ${method} ${path} answered ${response.status} with ${type}, not a ruact page. ` +
307
+ "The action already ran, so ruact will not submit the form again. " +
308
+ 'Add data-ruact="false" to this <form> to let the browser submit it, ' +
309
+ "or have the action answer through ruact.";
310
+ }
311
+
312
+ // Submits the form as the browser would, WITHOUT dispatching a `submit` event —
313
+ // so neither this router nor Turbo intercepts it again. The browser only sends
314
+ // the clicked button's `name=value` (and honours its `formaction` / `formmethod`
315
+ // / `formtarget`) when that button is the submitter, which `submit()` has no way
316
+ // to express: both are carried over onto the form first.
317
+ //
318
+ // Everything added to the form is taken back right after: `submit()` builds its
319
+ // entry list synchronously, and a page that survives the submit (a download, a
320
+ // 204, a return from the back-forward cache) must not keep the last button's
321
+ // overrides and value for the next one.
322
+ //
323
+ // A form rendered by a React component has no `authenticity_token` field — the
324
+ // router's own fetch sent the token as a header. A native submit cannot, so the
325
+ // token from `<meta name="csrf-token">` goes in as a field, the way rails-ujs
326
+ // does it.
327
+ const _SUBMITTER_OVERRIDES = [
328
+ ["formaction", "action"], ["formmethod", "method"], ["formtarget", "target"], ["formenctype", "enctype"],
329
+ ];
330
+
331
+ function _nativeSubmit(form, submitter) {
332
+ const restore = [];
333
+ const added = [];
334
+
335
+ if (submitter) {
336
+ for (const [override, attribute] of _SUBMITTER_OVERRIDES) {
337
+ if (!submitter.hasAttribute(override)) continue;
338
+ restore.push([attribute, form.hasAttribute(attribute) ? form.getAttribute(attribute) : null]);
339
+ form.setAttribute(attribute, submitter.getAttribute(override));
340
+ }
341
+ if (submitter.name) added.push(_hiddenField(submitter.name, submitter.value));
342
+ }
343
+
344
+ const csrfParam = document.querySelector('meta[name="csrf-param"]')?.content;
345
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
346
+ if (csrfParam && csrfToken && !_hasField(form, csrfParam)) added.push(_hiddenField(csrfParam, csrfToken));
347
+
348
+ added.forEach((field) => form.appendChild(field));
349
+ try {
350
+ HTMLFormElement.prototype.submit.call(form);
351
+ } finally {
352
+ added.forEach((field) => field.remove?.());
353
+ restore.forEach(([attribute, value]) => {
354
+ if (value === null) form.removeAttribute?.(attribute);
355
+ else form.setAttribute(attribute, value);
356
+ });
357
+ }
358
+ }
359
+
360
+ function _hiddenField(name, value) {
361
+ const field = document.createElement("input");
362
+ field.type = "hidden";
363
+ field.name = name;
364
+ field.value = value;
365
+ return field;
366
+ }
367
+
368
+ function _hasField(form, name) {
369
+ const quoted = name.replace(/["\\]/g, "\\$&");
370
+ return Boolean(form.elements?.namedItem?.(name) ?? form.querySelector?.(`[name="${quoted}"]`));
371
+ }
372
+
278
373
  async function _submitForm(form, submitter = null) {
279
374
  clearPendingChunks();
280
375
 
@@ -317,7 +412,18 @@ async function _submitForm(form, submitter = null) {
317
412
  headers,
318
413
  signal: controller.signal,
319
414
  });
320
- await _processFlightResponse(response, { push: true, targetUrl: action });
415
+ // Story 17.0f — not a ruact page, and the action did NOT run: this native
416
+ // submit is its one and only run, and its real response (a 422 with the
417
+ // validation errors included) is what the user sees.
418
+ if (_isNativeBoundary(response)) {
419
+ // The boundary answer came at the END of a redirect the fetch followed:
420
+ // the form's action already ran (a real 3xx, e.g. to another scheme), and
421
+ // resubmitting would run it twice. Load where it redirected to instead.
422
+ if (response.redirected) _fullLoad(response, action);
423
+ else _nativeSubmit(form, submitter);
424
+ return;
425
+ }
426
+ await _processFlightResponse(response, { push: true, targetUrl: action, method: htmlMethod });
321
427
  } catch (err) {
322
428
  if (err.name === "AbortError") return;
323
429
  console.error("[ruact-router] Form submission error:", err);
@@ -330,7 +436,9 @@ async function _submitForm(form, submitter = null) {
330
436
  // ---------------------------------------------------------------------------
331
437
 
332
438
  function handlePopstate() {
333
- navigate(location.pathname + location.search + location.hash, { push: false, scroll: false });
439
+ // Story 17.0f — `replace`: if this entry turns out not to be a ruact page, the
440
+ // full load must not push a NEW entry into the history being walked.
441
+ navigate(location.pathname + location.search + location.hash, { push: false, scroll: false, replace: true });
334
442
  }
335
443
 
336
444
  // ---------------------------------------------------------------------------
@@ -346,7 +454,7 @@ function handlePopstate() {
346
454
  * REJECTS on a failed Flight fetch — without this, callers cannot
347
455
  * branch on success vs. failure of a programmatic refetch.
348
456
  */
349
- async function navigate(url, { push = true, scroll = true, throwOnError = false } = {}) {
457
+ async function navigate(url, { push = true, scroll = true, throwOnError = false, replace = false } = {}) {
350
458
  // Clear stale lazy refs from any previous streaming navigation
351
459
  clearPendingChunks();
352
460
 
@@ -359,11 +467,21 @@ async function navigate(url, { push = true, scroll = true, throwOnError = false
359
467
  headers: { Accept: "text/x-component", "Ruact-Request": "1" },
360
468
  signal: controller.signal,
361
469
  });
470
+ // Story 17.0f — the server said the destination is not a ruact page, and
471
+ // ran nothing to say so. The browser takes it from here.
472
+ if (_isNativeBoundary(response)) {
473
+ // A caller that asked to be told (revalidate) refetches the page it is
474
+ // ON; "not a ruact page" there is a failure to report, not a reload.
475
+ if (throwOnError) throw new Error(`[ruact] ${url} is not a ruact page`);
476
+ _fullLoad(response, url, { replace });
477
+ return;
478
+ }
362
479
  await _processFlightResponse(response, {
363
480
  push,
364
481
  targetUrl: url,
365
482
  scroll,
366
483
  throwOnError,
484
+ replace,
367
485
  });
368
486
  } catch (err) {
369
487
  if (err.name === "AbortError") {
@@ -380,8 +498,47 @@ async function navigate(url, { push = true, scroll = true, throwOnError = false
380
498
  // Shared Flight response processor (used by navigate + _submitForm)
381
499
  // ---------------------------------------------------------------------------
382
500
 
383
- async function _processFlightResponse(response, { push, targetUrl, scroll = true, throwOnError = false }) {
384
- if (!response.ok) {
501
+ async function _processFlightResponse(response, {
502
+ push, targetUrl, scroll = true, throwOnError = false, replace = false, method = "GET",
503
+ }) {
504
+ // Story 17.0f — a response that is not Flight escaped the server's boundary
505
+ // classifier. Feeding it to the line parser was the old dead click: every HTML
506
+ // line parses to null, nothing renders, nothing reports.
507
+ //
508
+ // A non-Flight GET — a page, or an error page (404, 500, a 401 from auth) —
509
+ // is loaded in full so the user SEES it: the default app passes no onError,
510
+ // and a console line is a dead click by another name. Repeating a GET is
511
+ // harmless. A caller that asked to be told (`throwOnError`: revalidate)
512
+ // gets the error instead of a reload.
513
+ if (!_isFlight(response)) {
514
+ if (method === "GET" && !throwOnError) {
515
+ _fullLoad(response, targetUrl, { replace });
516
+ return;
517
+ }
518
+ // A form whose action answered HTML — it ran, so it will not be resubmitted.
519
+ // Only a 2xx or a 422 is that; a 403 (CSRF) or a 500 is a failure, below.
520
+ if (method !== "GET" && (response.ok || response.status === 422)) {
521
+ const err = new Error(_escapedFormMessage(method, targetUrl, response));
522
+ console.error(err.message);
523
+ _onError?.(err);
524
+ if (throwOnError) throw err;
525
+ return;
526
+ }
527
+ if (response.ok) {
528
+ const err = new Error(`[ruact] ${targetUrl} did not answer with a ruact response`);
529
+ console.error(err.message);
530
+ _onError?.(err);
531
+ if (throwOnError) throw err;
532
+ return;
533
+ }
534
+ }
535
+
536
+ // Story 17.0i — a Flight 422 is a page: `render :new, status:
537
+ // :unprocessable_entity` re-rendering a form with its validation errors. The
538
+ // errors are the content, not a transport failure. Every other non-2xx stays
539
+ // a failure — an HTML 422 (Rails' own error page) included.
540
+ const unprocessable = response.status === 422 && _isFlight(response);
541
+ if (!response.ok && !unprocessable) {
385
542
  const msg = `[ruact] Request failed: ${response.status} ${response.statusText}`;
386
543
  console.error(msg);
387
544
  const err = new Error(msg);
@@ -392,6 +549,12 @@ async function _processFlightResponse(response, { push, targetUrl, scroll = true
392
549
  return;
393
550
  }
394
551
 
552
+ // Story 17.0i — a form's 422 re-render keeps the form's URL: the POST URL is
553
+ // not one a reload or Back can ask for again (a reload of `/posts` would be
554
+ // the index). Unless the fetch followed a redirect: it then ended on a GET,
555
+ // and that URL is the page being shown.
556
+ const pushEntry = push && !(unprocessable && method !== "GET" && !response.redirected);
557
+
395
558
  // Use final URL after any redirects (response.url is the resolved URL).
396
559
  const finalPath = response.url
397
560
  ? (() => { const u = new URL(response.url); return u.pathname + u.search + u.hash; })()
@@ -422,7 +585,7 @@ async function _processFlightResponse(response, { push, targetUrl, scroll = true
422
585
  }
423
586
  // Normal case: build tree and render immediately.
424
587
  const tree = buildTreeFromRows(rows, _moduleRegistry);
425
- if (push) history.pushState(null, "", finalPath);
588
+ if (pushEntry) history.pushState(null, "", finalPath);
426
589
  _onNavigate(tree);
427
590
  if (scroll) window.scrollTo(0, 0);
428
591
  initialTreeSet = true;
@@ -466,7 +629,7 @@ async function _processFlightResponse(response, { push, targetUrl, scroll = true
466
629
  // Fallback: if row 0 never triggered (shouldn't happen with valid server)
467
630
  if (!initialTreeSet && !redirected && rows.has(0)) {
468
631
  const tree = buildTreeFromRows(rows, _moduleRegistry);
469
- if (push) history.pushState(null, "", finalPath);
632
+ if (pushEntry) history.pushState(null, "", finalPath);
470
633
  _onNavigate(tree);
471
634
  if (scroll) window.scrollTo(0, 0);
472
635
  }