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,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal React Flight wire format parser.
|
|
3
|
+
*
|
|
4
|
+
* Handles the subset we emit from the Ruby server:
|
|
5
|
+
* - Model rows: <hex_id>:<json>\n
|
|
6
|
+
* - Import rows: <hex_id>:I[moduleId, exportName, chunks]\n
|
|
7
|
+
*
|
|
8
|
+
* Returns a React element tree by recursively converting
|
|
9
|
+
* ["$", type, key, props] tuples into React.createElement calls.
|
|
10
|
+
*
|
|
11
|
+
* Supports Suspense streaming:
|
|
12
|
+
* - "$SS" element type → React.Suspense
|
|
13
|
+
* - "$L{hex}" referencing a missing row → React.lazy() that resolves when the row arrives
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createElement, Fragment, lazy, Suspense } from "react";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Pending chunk registry — used for streaming Suspense deferred rows
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const pendingChunks = new Map(); // rowId → { promise, resolve }
|
|
23
|
+
const lazyCache = new Map(); // rowId → React.lazy component (memoized)
|
|
24
|
+
|
|
25
|
+
/** Clear all pending lazy refs. Call at the start of each navigation. */
|
|
26
|
+
export function clearPendingChunks() {
|
|
27
|
+
pendingChunks.clear();
|
|
28
|
+
lazyCache.clear();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Called when a deferred model row arrives during streaming.
|
|
33
|
+
* Resolves the pending lazy component so React re-renders the Suspense boundary.
|
|
34
|
+
*
|
|
35
|
+
* @param {number} rowId
|
|
36
|
+
* @param {*} element - the React element tree built from the deferred row
|
|
37
|
+
*/
|
|
38
|
+
export function resolvePendingChunk(rowId, element) {
|
|
39
|
+
const chunk = pendingChunks.get(rowId);
|
|
40
|
+
if (chunk) {
|
|
41
|
+
chunk.resolve(element);
|
|
42
|
+
pendingChunks.delete(rowId);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createLazyForPending(rowId) {
|
|
47
|
+
if (lazyCache.has(rowId)) return lazyCache.get(rowId);
|
|
48
|
+
|
|
49
|
+
let resolve;
|
|
50
|
+
const promise = new Promise((r) => { resolve = r; });
|
|
51
|
+
pendingChunks.set(rowId, { promise, resolve });
|
|
52
|
+
|
|
53
|
+
// React.lazy expects { default: ComponentType }. We wrap the element in a function component.
|
|
54
|
+
const LazyComp = lazy(() => promise.then((el) => ({ default: () => el })));
|
|
55
|
+
lazyCache.set(rowId, LazyComp);
|
|
56
|
+
return LazyComp;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Row parsing
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse a single Flight wire format line into { id, row }.
|
|
65
|
+
* Returns null for blank or malformed lines.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} line
|
|
68
|
+
* @returns {{ id: number, row: object } | null}
|
|
69
|
+
*/
|
|
70
|
+
export function parseLine(line) {
|
|
71
|
+
if (!line.trim()) return null;
|
|
72
|
+
|
|
73
|
+
const colonIdx = line.indexOf(":");
|
|
74
|
+
if (colonIdx === -1) return null;
|
|
75
|
+
|
|
76
|
+
const id = parseInt(line.slice(0, colonIdx), 16);
|
|
77
|
+
const rest = line.slice(colonIdx + 1);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
if (rest.startsWith("I")) {
|
|
81
|
+
const [moduleId, exportName] = JSON.parse(rest.slice(1));
|
|
82
|
+
return { id, row: { kind: "import", moduleId, exportName } };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (rest.startsWith("E")) {
|
|
86
|
+
const errorData = JSON.parse(rest.slice(1));
|
|
87
|
+
const message = typeof errorData === "string"
|
|
88
|
+
? errorData
|
|
89
|
+
: (errorData.message || String(errorData));
|
|
90
|
+
return { id, row: { kind: "error", message } };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { id, row: { kind: "model", value: JSON.parse(rest) } };
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.warn("[flight-client] Skipping malformed row:", line, e);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Tree building
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build a React element tree from a fully-populated rows Map.
|
|
106
|
+
*
|
|
107
|
+
* @param {Map} rows - id → { kind, ... } rows
|
|
108
|
+
* @param {Object} moduleRegistry - { [moduleId]: { [exportName]: Component } }
|
|
109
|
+
* @returns React element tree
|
|
110
|
+
*/
|
|
111
|
+
export function buildTreeFromRows(rows, moduleRegistry) {
|
|
112
|
+
const root = rows.get(0);
|
|
113
|
+
if (!root) throw new Error("[flight-client] No root row (id=0) found in payload");
|
|
114
|
+
if (root.kind === "error") throw new Error(`[ruact] Server error: ${root.message}`);
|
|
115
|
+
if (root.kind !== "model") throw new Error("[flight-client] Root row is not a model row");
|
|
116
|
+
return buildTree(root.value, rows, moduleRegistry);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build a React element tree from a single row value.
|
|
121
|
+
* Used when resolving a deferred (Suspense) row that has arrived via streaming.
|
|
122
|
+
*
|
|
123
|
+
* @param {*} value
|
|
124
|
+
* @param {Map} rows
|
|
125
|
+
* @param {Object} moduleRegistry
|
|
126
|
+
*/
|
|
127
|
+
export function buildTree(value, rows, moduleRegistry) {
|
|
128
|
+
return _buildTree(value, rows, moduleRegistry);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Parse a Flight payload string and return a React element tree.
|
|
133
|
+
* Convenience wrapper — used for the initial (non-streaming) page load.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} payload
|
|
136
|
+
* @param {Object} moduleRegistry
|
|
137
|
+
*/
|
|
138
|
+
export function createFromFlightPayload(payload, moduleRegistry) {
|
|
139
|
+
const rows = new Map();
|
|
140
|
+
for (const line of payload.split("\n")) {
|
|
141
|
+
const parsed = parseLine(line);
|
|
142
|
+
if (parsed) rows.set(parsed.id, parsed.row);
|
|
143
|
+
}
|
|
144
|
+
return buildTreeFromRows(rows, moduleRegistry);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Internal helpers
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
function _buildTree(value, rows, moduleRegistry) {
|
|
152
|
+
if (value === null || value === undefined) return value;
|
|
153
|
+
|
|
154
|
+
// --- Strings with special $ prefixes ---
|
|
155
|
+
if (typeof value === "string") {
|
|
156
|
+
if (value.startsWith("$$")) return value.slice(1); // escaped $
|
|
157
|
+
if (value === "$undefined") return undefined;
|
|
158
|
+
if (value === "$NaN") return NaN;
|
|
159
|
+
if (value === "$Infinity") return Infinity;
|
|
160
|
+
if (value === "$-Infinity") return -Infinity;
|
|
161
|
+
if (value === "$-0") return -0;
|
|
162
|
+
if (value.startsWith("$L")) {
|
|
163
|
+
const refId = parseInt(value.slice(2), 16);
|
|
164
|
+
const row = rows.get(refId);
|
|
165
|
+
|
|
166
|
+
if (!row) {
|
|
167
|
+
// Row hasn't arrived yet — create a lazy component that suspends until it does
|
|
168
|
+
return createLazyForPending(refId);
|
|
169
|
+
}
|
|
170
|
+
if (row.kind === "error") {
|
|
171
|
+
throw new Error(`[ruact] Server error: ${row.message}`);
|
|
172
|
+
}
|
|
173
|
+
if (row.kind === "import") {
|
|
174
|
+
const mod = moduleRegistry[row.moduleId];
|
|
175
|
+
if (!mod) throw new Error(`[flight-client] Module not registered: ${row.moduleId}`);
|
|
176
|
+
const component = mod[row.exportName];
|
|
177
|
+
if (!component) throw new Error(`[flight-client] Export "${row.exportName}" not found in ${row.moduleId}`);
|
|
178
|
+
return component;
|
|
179
|
+
}
|
|
180
|
+
// Model row — deferred content already arrived (non-streaming path).
|
|
181
|
+
// Wrap in a function component so it can be used as a type in createElement.
|
|
182
|
+
const content = _buildTree(row.value, rows, moduleRegistry);
|
|
183
|
+
return () => content;
|
|
184
|
+
}
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- Arrays ---
|
|
189
|
+
if (Array.isArray(value)) {
|
|
190
|
+
// React element tuple: ["$", type, key, props]
|
|
191
|
+
if (value[0] === "$") {
|
|
192
|
+
const [, rawType, key, rawProps] = value;
|
|
193
|
+
const type = resolveType(rawType, rows, moduleRegistry);
|
|
194
|
+
const props = buildProps(rawProps, rows, moduleRegistry);
|
|
195
|
+
if (key != null) props.key = key;
|
|
196
|
+
return createElement(type, props);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Plain array / fragment children
|
|
200
|
+
const items = value.map((v) => _buildTree(v, rows, moduleRegistry));
|
|
201
|
+
return items.length === 1 ? items[0] : items;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- Plain objects ---
|
|
205
|
+
if (typeof value === "object") {
|
|
206
|
+
return Object.fromEntries(
|
|
207
|
+
Object.entries(value).map(([k, v]) => [k, _buildTree(v, rows, moduleRegistry)])
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return value;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function resolveType(rawType, rows, moduleRegistry) {
|
|
215
|
+
if (typeof rawType === "string") {
|
|
216
|
+
if (rawType === "$SS") return Suspense; // React.Suspense
|
|
217
|
+
if (rawType.startsWith("$L")) return _buildTree(rawType, rows, moduleRegistry); // lazy / import
|
|
218
|
+
return rawType;
|
|
219
|
+
}
|
|
220
|
+
return rawType;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function buildProps(rawProps, rows, moduleRegistry) {
|
|
224
|
+
if (!rawProps) return {};
|
|
225
|
+
const props = {};
|
|
226
|
+
for (const [key, val] of Object.entries(rawProps)) {
|
|
227
|
+
if (key === "children") {
|
|
228
|
+
const children = _buildTree(val, rows, moduleRegistry);
|
|
229
|
+
if (children !== undefined) props.children = children;
|
|
230
|
+
} else {
|
|
231
|
+
props[key] = _buildTree(val, rows, moduleRegistry);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return props;
|
|
235
|
+
}
|