ruact 0.0.4 → 0.0.6

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +68 -17
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
  93. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
  94. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  95. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  96. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  97. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  98. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  99. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  102. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  104. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  107. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  119. metadata +55 -15
  120. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  121. data/lib/ruact/server_action.rb +0 -131
  122. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  123. data/lib/ruact/server_functions/registry.rb +0 -148
  124. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  125. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  126. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  127. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  128. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  129. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  130. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  131. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  132. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -0,0 +1,473 @@
1
+ /**
2
+ * RSC client-side router.
3
+ *
4
+ * Intercepts same-origin <a> clicks and <form> submits, fetches the Flight
5
+ * payload for the new URL via ReadableStream (incremental), and:
6
+ * 1. Calls onNavigate(tree) as soon as row 0 arrives (shows Suspense fallback)
7
+ * 2. Resolves deferred Suspense rows as they stream in (swaps in actual content)
8
+ *
9
+ * Also handles popstate for browser back/forward.
10
+ */
11
+
12
+ import {
13
+ parseLine,
14
+ buildTreeFromRows,
15
+ buildTree,
16
+ resolvePendingChunk,
17
+ clearPendingChunks,
18
+ } from "./flight-client.js";
19
+
20
+ let _onNavigate = null;
21
+ let _moduleRegistry = null;
22
+ let _onError = null;
23
+ let _currentAbort = null;
24
+
25
+ /**
26
+ * @param {object} opts
27
+ * @param {function} opts.onNavigate - called with the new React element tree
28
+ * @param {object} opts.moduleRegistry - passed through to flight-client
29
+ * @param {function} [opts.onError] - called with an Error when navigation fails
30
+ */
31
+ export function setupRouter({ onNavigate, moduleRegistry, onError = null }) {
32
+ _onNavigate = onNavigate;
33
+ _moduleRegistry = moduleRegistry;
34
+ _onError = onError;
35
+
36
+ document.addEventListener("click", handleClick, { capture: true });
37
+ document.addEventListener("submit", handleSubmit, { capture: true });
38
+ window.addEventListener("popstate", handlePopstate);
39
+
40
+ // Story 8.2 — publish the revalidate handle the runtime helper reads.
41
+ // `push: false` so a revalidate does NOT add a history entry —
42
+ // semantically it's a refetch-in-place, not a navigation.
43
+ // `scroll: false` keeps the viewport stable after the React tree swap.
44
+ // `throwOnError: true` (Story 8.2 review patch R7, 2026-05-17) makes
45
+ // `await revalidate()` reject on a failed Flight fetch — the
46
+ // default navigate() path swallows-and-forwards to onError, which is
47
+ // what link clicks and form submits want, but a programmatic
48
+ // `revalidate()` caller HAS to be able to branch on success vs.
49
+ // failure.
50
+ globalThis.__ruact_revalidate = (target) =>
51
+ navigate(target ?? location.pathname + location.search, {
52
+ push: false,
53
+ scroll: false,
54
+ throwOnError: true,
55
+ });
56
+ }
57
+
58
+ export function teardownRouter() {
59
+ document.removeEventListener("click", handleClick, { capture: true });
60
+ document.removeEventListener("submit", handleSubmit, { capture: true });
61
+ window.removeEventListener("popstate", handlePopstate);
62
+ _onNavigate = null;
63
+ _moduleRegistry = null;
64
+ _onError = null;
65
+ _currentAbort = null;
66
+ // Story 8.2 — tear down the revalidate handle so a subsequent
67
+ // setupRouter()-less code path (e.g., SSR) fails LOUDLY at the first
68
+ // revalidate() call instead of using a stale handle.
69
+ if (globalThis.__ruact_revalidate) delete globalThis.__ruact_revalidate;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Link interception
74
+ // ---------------------------------------------------------------------------
75
+
76
+ export function shouldIntercept(anchor) {
77
+ if (!anchor) return false;
78
+ if (anchor.getAttribute("data-ruact") === "false") return false;
79
+ if (anchor.target && anchor.target !== "_self") return false;
80
+ if (anchor.hasAttribute("download")) return false;
81
+
82
+ const href = anchor.getAttribute("href") || "";
83
+ if (!href || href.startsWith("#")) return false;
84
+ if (/^(mailto:|tel:|javascript:)/i.test(href)) return false;
85
+
86
+ try {
87
+ const url = new URL(href, location.href);
88
+ if (url.origin !== location.origin) return false;
89
+ } catch {
90
+ return false;
91
+ }
92
+
93
+ return true;
94
+ }
95
+
96
+ function handleClick(event) {
97
+ if (event.defaultPrevented) return;
98
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
99
+ if (event.button !== 0) return;
100
+
101
+ const anchor = event.target.closest("a[href]");
102
+ if (!shouldIntercept(anchor)) return;
103
+
104
+ event.preventDefault();
105
+
106
+ const url = new URL(anchor.getAttribute("href"), location.href);
107
+ navigate(url.pathname + url.search + url.hash);
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Form interception
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /**
115
+ * Returns true if the form submission should be intercepted by the RSC router.
116
+ *
117
+ * Story 8.2 review patches R5 + R6 (2026-05-17) + R15 (2026-05-17):
118
+ * - R5: `<button formAction=...>` overrides the form's `action`. Empty
119
+ * `formaction=""` is a VALID override (submit to current URL); only
120
+ * the absence of the attribute falls through to `form.action`.
121
+ * - R6: `<form method="dialog">` AND `<button formmethod="dialog">`
122
+ * are browser primitives that close the enclosing `<dialog>` — the
123
+ * router has no business fetching anything for either.
124
+ * - R15: `<button formtarget="_blank">` overrides the form's `target`.
125
+ * Effective method/target/action are resolved BEFORE the guard so
126
+ * every native override participates in the interception decision.
127
+ */
128
+ export function shouldInterceptForm(form, submitter = null) {
129
+ if (!form || form.tagName !== "FORM") return false;
130
+ if (form.getAttribute("data-ruact") === "false") return false;
131
+
132
+ // R15: resolve effective method/target/action HONORING submitter
133
+ // overrides BEFORE the guard, so dialog/cross-origin checks see what
134
+ // the browser would actually use.
135
+ const { method, target, action, actionExplicitlyEmpty } = _effectiveSubmission(form, submitter);
136
+
137
+ // R6 + R15: method="dialog" — either at the form level or via the
138
+ // submitter's `formmethod` — closes the enclosing <dialog>; don't
139
+ // fetch anything.
140
+ if (method === "dialog") return false;
141
+
142
+ // R15: native target check uses the effective target so a
143
+ // `<button formtarget="_blank">` opt-out is honored.
144
+ if (target && target !== "_self") return false;
145
+
146
+ // R5 + R15: empty-string action ("") is a VALID native override
147
+ // meaning "submit to current URL" — only the missing-attribute path
148
+ // falls through to "submit to current URL" via the same default. A
149
+ // truly missing action (null + no submitter override) also means
150
+ // current-URL, so both paths return true here.
151
+ if (action === "" || action == null) return true;
152
+ if (actionExplicitlyEmpty) return true;
153
+
154
+ try {
155
+ const url = new URL(action, location.href);
156
+ if (url.origin !== location.origin) return false;
157
+ } catch {
158
+ return false;
159
+ }
160
+
161
+ return true;
162
+ }
163
+
164
+ /**
165
+ * R15: resolves the EFFECTIVE method / target / action of a submit
166
+ * event, honoring submitter overrides per the browser-native algorithm.
167
+ * Centralized so `shouldInterceptForm` and `_submitForm` see the same
168
+ * resolution — pre-R15 they computed each piece in separate places and
169
+ * drifted (e.g. submitter `formmethod` was used at request time but
170
+ * not at guard time).
171
+ *
172
+ * `actionExplicitlyEmpty` distinguishes `formaction=""` (a valid native
173
+ * override) from "submitter present but no formaction attribute" (fall
174
+ * through to `form.action`). The empty-string override means "submit to
175
+ * the current URL" regardless of what the form's `action` says.
176
+ */
177
+ function _effectiveSubmission(form, submitter) {
178
+ const hasAttr = (el, name) =>
179
+ el && typeof el.hasAttribute === "function" && el.hasAttribute(name);
180
+
181
+ const submitterHasAction = hasAttr(submitter, "formAction");
182
+ const submitterHasMethod = hasAttr(submitter, "formMethod");
183
+ const submitterHasTarget = hasAttr(submitter, "formTarget");
184
+ const submitterAction = submitterHasAction ? submitter.getAttribute("formAction") : null;
185
+ const submitterMethod = submitterHasMethod ? submitter.getAttribute("formMethod") : null;
186
+ const submitterTarget = submitterHasTarget ? submitter.getAttribute("formTarget") : null;
187
+
188
+ // R15: empty `formaction=""` IS a valid override (submit to current
189
+ // URL). Use the `hasAttribute` check to disambiguate "explicit empty"
190
+ // from "absent attribute" — only the latter falls through to
191
+ // `form.action`.
192
+ const action = submitterHasAction ? submitterAction : form.getAttribute("action");
193
+ const actionExplicitlyEmpty = submitterHasAction && submitterAction === "";
194
+
195
+ // R18 (2026-05-17): same fix for `formtarget`. A present-but-empty
196
+ // submitter `formtarget=""` is a VALID override that resets the
197
+ // effective target to the default (same-window, equivalent to `_self`).
198
+ // Previously the `||` fallthrough discarded the empty submitter
199
+ // override and let the form-level `target="_blank"` win, causing the
200
+ // router to skip an opt-in same-window submission.
201
+ // `formmethod=""` follows the same rule for consistency.
202
+ const target = submitterHasTarget ? submitterTarget : form.getAttribute("target");
203
+ const method = submitterHasMethod
204
+ ? submitterMethod
205
+ : form.getAttribute("method");
206
+
207
+ return {
208
+ method: (method || "get").toLowerCase(),
209
+ target: target || null, // empty string normalizes to null (default same-window)
210
+ action,
211
+ actionExplicitlyEmpty,
212
+ };
213
+ }
214
+
215
+ function handleSubmit(event) {
216
+ if (event.defaultPrevented) return;
217
+
218
+ const form = event.target;
219
+ // R5: pass `event.submitter` so the guard inspects the button's
220
+ // `formAction` override when one was clicked. React 19 sets
221
+ // `event.submitter.formAction` to its `javascript:throw` placeholder
222
+ // for function-action buttons; the URL-origin check in
223
+ // `shouldInterceptForm` rejects it cleanly, preventing a double-fire
224
+ // when the form's own `action` is still a parseable same-origin URL.
225
+ if (!shouldInterceptForm(form, event.submitter)) return;
226
+
227
+ event.preventDefault();
228
+ // R11: forward the submitter to `_submitForm` so the actual fetch
229
+ // honors the submitter's `formaction` / `formmethod` overrides AND
230
+ // includes the submitter's name/value in the FormData payload — the
231
+ // browser's default form-submit behaviour. Without this, a same-
232
+ // origin `<button formaction="/other">` would pass interception but
233
+ // the router would still fetch the form's own `action`, and a
234
+ // `<button name="op" value="delete">` clicked submitter would be
235
+ // dropped from the body.
236
+ _submitForm(form, event.submitter);
237
+ }
238
+
239
+ // R11 + R15: resolves the EFFECTIVE submission action/method for the
240
+ // REQUEST (separate from `_effectiveSubmission` which is the resolver
241
+ // used by `shouldInterceptForm`). The two are aligned but the request
242
+ // resolver normalizes against `form.action` (absolute URL from the IDL
243
+ // property when both submitter and attribute are absent) and uppercases
244
+ // the method for the dispatch switch.
245
+ function _resolveSubmission(form, submitter) {
246
+ const eff = _effectiveSubmission(form, submitter);
247
+ // R15: an explicit empty action ("") means "submit to the current
248
+ // URL" — translate to location.href so the fetch has a valid URL.
249
+ // Same for an absent action: the IDL property `form.action` reflects
250
+ // the page URL when the attribute is missing.
251
+ const action =
252
+ eff.action == null
253
+ ? form.action || location.href
254
+ : eff.action === ""
255
+ ? location.href
256
+ : eff.action;
257
+ return {
258
+ action,
259
+ method: eff.method.toUpperCase(),
260
+ };
261
+ }
262
+
263
+ // R11: builds the FormData payload that mirrors what a native browser
264
+ // submit would send. `new FormData(form, submitter)` is the second-
265
+ // argument shape standardised in 2021 — it includes the submitter's
266
+ // `name=value` pair so a `<button name="op" value="delete">` round-
267
+ // trips correctly. Falls back to single-arg construction if the
268
+ // browser doesn't accept the submitter parameter (older test
269
+ // environments / jsdom < 22).
270
+ function _buildFormData(form, submitter) {
271
+ try {
272
+ return submitter ? new FormData(form, submitter) : new FormData(form);
273
+ } catch {
274
+ return new FormData(form);
275
+ }
276
+ }
277
+
278
+ async function _submitForm(form, submitter = null) {
279
+ clearPendingChunks();
280
+
281
+ // R11: resolve the effective action and method from the submitter's
282
+ // overrides before falling back to the form's attributes. The
283
+ // previous implementation always read `form.action` and the form's
284
+ // `method`, ignoring `<button formaction>` / `<button formmethod>`.
285
+ const { action, method: htmlMethod } = _resolveSubmission(form, submitter);
286
+
287
+ if (htmlMethod === "GET") {
288
+ // Serialize form fields to query string and navigate as GET.
289
+ // R11: also pull the submitter's name/value into the query string
290
+ // via `new FormData(form, submitter)` so the navigated URL
291
+ // matches what a native browser submit would produce.
292
+ const url = new URL(action, location.href);
293
+ _buildFormData(form, submitter).forEach((v, k) =>
294
+ url.searchParams.append(k, String(v)),
295
+ );
296
+ navigate(url.pathname + url.search + url.hash);
297
+ return;
298
+ }
299
+
300
+ // POST/PUT/PATCH/DELETE: always send as POST.
301
+ // Rails _method hidden field is included in FormData automatically;
302
+ // Rack::MethodOverride reads it and routes to the correct action.
303
+ _currentAbort?.abort();
304
+ const controller = (_currentAbort = new AbortController());
305
+
306
+ const headers = { Accept: "text/x-component", "Ruact-Request": "1" };
307
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
308
+ if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
309
+
310
+ try {
311
+ const response = await fetch(action, {
312
+ method: "POST",
313
+ // R11: include the submitter's name/value in the payload (browser
314
+ // default for native submits). Single-arg fallback if the
315
+ // environment doesn't accept the submitter parameter.
316
+ body: _buildFormData(form, submitter),
317
+ headers,
318
+ signal: controller.signal,
319
+ });
320
+ await _processFlightResponse(response, { push: true, targetUrl: action });
321
+ } catch (err) {
322
+ if (err.name === "AbortError") return;
323
+ console.error("[ruact-router] Form submission error:", err);
324
+ _onError?.(err);
325
+ }
326
+ }
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // Popstate (Back / Forward)
330
+ // ---------------------------------------------------------------------------
331
+
332
+ function handlePopstate() {
333
+ navigate(location.pathname + location.search + location.hash, { push: false, scroll: false });
334
+ }
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Navigation
338
+ // ---------------------------------------------------------------------------
339
+
340
+ /**
341
+ * Story 8.2 review patch R7 (2026-05-17) — `throwOnError` opts a caller
342
+ * out of the swallow-and-forward error mode. The default (false) is the
343
+ * Phase 1 contract: navigation errors are logged + forwarded to the
344
+ * onError callback, then the promise resolves. The opt-in (true) is
345
+ * used by the `revalidate()` runtime helper so `await revalidate()`
346
+ * REJECTS on a failed Flight fetch — without this, callers cannot
347
+ * branch on success vs. failure of a programmatic refetch.
348
+ */
349
+ async function navigate(url, { push = true, scroll = true, throwOnError = false } = {}) {
350
+ // Clear stale lazy refs from any previous streaming navigation
351
+ clearPendingChunks();
352
+
353
+ // Abort any in-flight fetch so we don't apply a stale response
354
+ _currentAbort?.abort();
355
+ const controller = (_currentAbort = new AbortController());
356
+
357
+ try {
358
+ const response = await fetch(url, {
359
+ headers: { Accept: "text/x-component", "Ruact-Request": "1" },
360
+ signal: controller.signal,
361
+ });
362
+ await _processFlightResponse(response, {
363
+ push,
364
+ targetUrl: url,
365
+ scroll,
366
+ throwOnError,
367
+ });
368
+ } catch (err) {
369
+ if (err.name === "AbortError") {
370
+ if (throwOnError) throw err;
371
+ return;
372
+ }
373
+ console.error("[ruact-router] Navigation error:", err);
374
+ _onError?.(err);
375
+ if (throwOnError) throw err;
376
+ }
377
+ }
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // Shared Flight response processor (used by navigate + _submitForm)
381
+ // ---------------------------------------------------------------------------
382
+
383
+ async function _processFlightResponse(response, { push, targetUrl, scroll = true, throwOnError = false }) {
384
+ if (!response.ok) {
385
+ const msg = `[ruact] Request failed: ${response.status} ${response.statusText}`;
386
+ console.error(msg);
387
+ const err = new Error(msg);
388
+ _onError?.(err);
389
+ // R7: surface non-ok responses to revalidate()'s awaiter so callers
390
+ // can branch on failure instead of seeing a silently-resolved promise.
391
+ if (throwOnError) throw err;
392
+ return;
393
+ }
394
+
395
+ // Use final URL after any redirects (response.url is the resolved URL).
396
+ const finalPath = response.url
397
+ ? (() => { const u = new URL(response.url); return u.pathname + u.search + u.hash; })()
398
+ : targetUrl;
399
+
400
+ const rows = new Map();
401
+ let initialTreeSet = false;
402
+ let redirected = false;
403
+
404
+ const processLine = (line) => {
405
+ const parsed = parseLine(line);
406
+ if (!parsed) return;
407
+
408
+ rows.set(parsed.id, parsed.row);
409
+
410
+ if (!initialTreeSet && rows.has(0)) {
411
+ const rootRow = rows.get(0);
412
+ // Flight redirect instruction — delegate to navigate() and bail out.
413
+ if (rootRow.kind === "model" && rootRow.value != null && rootRow.value.redirectUrl) {
414
+ // Validate redirect is same-origin before following
415
+ try {
416
+ const rurl = new URL(rootRow.value.redirectUrl, location.href);
417
+ if (rurl.origin !== location.origin) return;
418
+ } catch { return; }
419
+ redirected = true;
420
+ navigate(rootRow.value.redirectUrl, { push: rootRow.value.redirectType !== "replace" });
421
+ return;
422
+ }
423
+ // Normal case: build tree and render immediately.
424
+ const tree = buildTreeFromRows(rows, _moduleRegistry);
425
+ if (push) history.pushState(null, "", finalPath);
426
+ _onNavigate(tree);
427
+ if (scroll) window.scrollTo(0, 0);
428
+ initialTreeSet = true;
429
+ return;
430
+ }
431
+
432
+ if (initialTreeSet && parsed.id !== 0) {
433
+ if (parsed.row.kind === "error") {
434
+ // Deferred error row — propagate via onError callback
435
+ _onError?.(new Error(`[ruact] Server error: ${parsed.row.message}`));
436
+ return;
437
+ }
438
+ if (parsed.row.kind === "model") {
439
+ // Deferred content row — resolve pending lazy chunk.
440
+ const element = buildTree(parsed.row.value, rows, _moduleRegistry);
441
+ resolvePendingChunk(parsed.id, element);
442
+ }
443
+ }
444
+ };
445
+
446
+ const reader = response.body.getReader();
447
+ const decoder = new TextDecoder();
448
+ let buffer = "";
449
+
450
+ while (true) {
451
+ const { done, value } = await reader.read();
452
+ if (done) break;
453
+
454
+ buffer += decoder.decode(value, { stream: true });
455
+
456
+ let newlineIdx;
457
+ while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
458
+ processLine(buffer.slice(0, newlineIdx));
459
+ buffer = buffer.slice(newlineIdx + 1);
460
+ }
461
+ }
462
+
463
+ // Handle any remaining content without a trailing newline
464
+ if (buffer.trim()) processLine(buffer);
465
+
466
+ // Fallback: if row 0 never triggered (shouldn't happen with valid server)
467
+ if (!initialTreeSet && !redirected && rows.has(0)) {
468
+ const tree = buildTreeFromRows(rows, _moduleRegistry);
469
+ if (push) history.pushState(null, "", finalPath);
470
+ _onNavigate(tree);
471
+ if (scroll) window.scrollTo(0, 0);
472
+ }
473
+ }