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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +68 -17
- data/bench/server_functions_dispatch_bench.rb +109 -142
- data/bench/server_functions_dispatch_bench.results.md +29 -0
- data/docs/internal/decisions/server-functions-api.md +402 -0
- data/lib/generators/ruact/install/install_generator.rb +310 -25
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
- data/lib/generators/ruact/install/templates/dev.tt +16 -0
- data/lib/generators/ruact/install/templates/package.json.tt +17 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
- data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
- data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
- data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
- data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
- data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
- data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
- data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
- data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
- data/lib/ruact/client_manifest.rb +37 -36
- data/lib/ruact/component_contract.rb +115 -0
- data/lib/ruact/configuration.rb +80 -28
- data/lib/ruact/controller.rb +69 -421
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +65 -12
- data/lib/ruact/erb_preprocessor_hook.rb +4 -1
- data/lib/ruact/errors.rb +30 -44
- data/lib/ruact/html_converter.rb +22 -1
- data/lib/ruact/railtie.rb +56 -200
- data/lib/ruact/server.rb +28 -6
- data/lib/ruact/server_functions/codegen.rb +49 -188
- data/lib/ruact/server_functions/codegen_v2.rb +23 -6
- data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
- data/lib/ruact/server_functions/error_payload.rb +1 -1
- data/lib/ruact/server_functions/error_rendering.rb +22 -29
- data/lib/ruact/server_functions/name_bridge.rb +14 -16
- data/lib/ruact/server_functions/query_source.rb +35 -8
- data/lib/ruact/server_functions/route_source.rb +3 -4
- data/lib/ruact/server_functions/snapshot.rb +12 -139
- data/lib/ruact/server_functions/validation_errors.rb +70 -0
- data/lib/ruact/server_functions.rb +21 -25
- data/lib/ruact/signed_references.rb +162 -0
- data/lib/ruact/string_distance.rb +72 -0
- data/lib/ruact/validation_errors_collector.rb +139 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +102 -0
- data/lib/ruact.rb +19 -19
- data/lib/tasks/ruact.rake +10 -53
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +36 -0
- data/spec/ruact/component_contract_spec.rb +119 -0
- data/spec/ruact/configuration_spec.rb +51 -34
- data/spec/ruact/controller_request_spec.rb +264 -0
- data/spec/ruact/controller_spec.rb +63 -326
- data/spec/ruact/doctor_spec.rb +201 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
- data/spec/ruact/erb_preprocessor_spec.rb +127 -0
- data/spec/ruact/errors_spec.rb +0 -45
- data/spec/ruact/html_converter_spec.rb +50 -0
- data/spec/ruact/install_generator_spec.rb +591 -4
- data/spec/ruact/query_request_spec.rb +109 -1
- data/spec/ruact/scaffold_generator_spec.rb +1835 -0
- data/spec/ruact/server_bucket_request_spec.rb +142 -0
- data/spec/ruact/server_function_name_spec.rb +1 -1
- data/spec/ruact/server_functions/codegen_spec.rb +158 -269
- data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
- data/spec/ruact/server_functions/query_source_spec.rb +51 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
- data/spec/ruact/server_functions/rake_spec.rb +29 -29
- data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
- data/spec/ruact/server_rescue_request_spec.rb +6 -6
- data/spec/ruact/server_spec.rb +8 -9
- data/spec/ruact/signed_references_spec.rb +164 -0
- data/spec/ruact/string_distance_spec.rb +38 -0
- data/spec/ruact/validation_errors_spec.rb +116 -0
- data/spec/ruact/view_helper_spec.rb +79 -0
- data/spec/spec_helper.rb +0 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
- data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
- data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
- metadata +55 -15
- data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
- data/lib/ruact/server_action.rb +0 -131
- data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
- data/lib/ruact/server_functions/registry.rb +0 -148
- data/lib/ruact/server_functions/registry_entry.rb +0 -26
- data/lib/ruact/server_functions/standalone_context.rb +0 -103
- data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
- data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
- data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
- data/spec/ruact/server_functions/registry_spec.rb +0 -199
- data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
- data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
- 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
|
+
}
|