ruact 0.0.15 → 0.0.17

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/lib/generators/ruact/install/install_generator.rb +2 -1
  4. data/lib/generators/ruact/install/templates/AGENTS.md.tt +2 -2
  5. data/lib/ruact/client_manifest.rb +11 -2
  6. data/lib/ruact/component_attributes.rb +91 -0
  7. data/lib/ruact/configuration.rb +28 -0
  8. data/lib/ruact/controller.rb +3 -1
  9. data/lib/ruact/doctor.rb +64 -4
  10. data/lib/ruact/erb_preprocessor.rb +1 -47
  11. data/lib/ruact/flight/renderer.rb +23 -9
  12. data/lib/ruact/flight/request.rb +31 -2
  13. data/lib/ruact/flight/row_emitter.rb +9 -0
  14. data/lib/ruact/flight/serializer.rb +22 -7
  15. data/lib/ruact/render_pipeline.rb +10 -3
  16. data/lib/ruact/server.rb +11 -7
  17. data/lib/ruact/server_functions/action_assigns.rb +52 -0
  18. data/lib/ruact/server_functions/bucket_two_payload.rb +1 -1
  19. data/lib/ruact/testing/component_query.rb +11 -4
  20. data/lib/ruact/testing/flight_wire_parser.rb +2 -2
  21. data/lib/ruact/version.rb +1 -1
  22. data/lib/ruact.rb +1 -0
  23. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +44 -2
  24. data/vendor/javascript/vite-plugin-ruact/flight-conformance.test.mjs +286 -0
  25. data/vendor/javascript/vite-plugin-ruact/index.js +89 -14
  26. data/vendor/javascript/vite-plugin-ruact/package-lock.json +804 -3
  27. data/vendor/javascript/vite-plugin-ruact/package.json +5 -1
  28. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +53 -0
  29. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +46 -17
  30. data/vendor/javascript/vite-plugin-ruact/runtime/flight-modules.js +40 -0
  31. data/vendor/javascript/vite-plugin-ruact/runtime/flight-webpack-globals.js +15 -0
  32. data/vendor/javascript/vite-plugin-ruact/runtime/root-boundary.js +41 -0
  33. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +75 -88
  34. data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +83 -0
  35. data/vendor/javascript/vite-plugin-ruact/runtime/transport.js +41 -0
  36. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/LICENSE +21 -0
  37. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/VERSION.json +5 -0
  38. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.development.js +5473 -0
  39. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.production.js +2126 -0
  40. data/vendor/javascript/vite-plugin-ruact/scripts/vendor-flight-client.mjs +124 -0
  41. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +14 -0
  42. metadata +15 -4
  43. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +0 -452
  44. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +0 -408
@@ -1,408 +0,0 @@
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
- * - Error rows: <hex_id>:E<json>\n
8
- * - Text rows: <hex_id>:T<hex_byte_length>,<utf-8 text> (NO newline)
9
- *
10
- * Every string of 1024 bytes or more travels as a text row, framed by its
11
- * byte length, and is referenced from a model row as "$T<hex_id>". Both the
12
- * initial load and the router read rows through ONE incremental byte parser
13
- * ({@link createRowParser}), so a payload decodes the same whichever way it
14
- * arrives and wherever the network splits it.
15
- *
16
- * Values JSON cannot hold travel as "$"-prefixed strings and are rebuilt:
17
- * "$D<ISO 8601>" → Date, "$n<decimal>" → BigInt, "$NaN"/"$Infinity"/… →
18
- * the number, "$undefined" → undefined. A literal string that starts with
19
- * "$" arrives escaped as "$$…".
20
- *
21
- * Returns a React element tree by recursively converting
22
- * ["$", type, key, props] tuples into React.createElement calls.
23
- *
24
- * Supports Suspense streaming:
25
- * - "$SS" element type → React.Suspense
26
- * - "$L{hex}" referencing a missing row → React.lazy() that resolves when the row arrives
27
- */
28
-
29
- import { createElement, Fragment, lazy, Suspense } from "react";
30
-
31
- // ---------------------------------------------------------------------------
32
- // Pending chunk registry — used for streaming Suspense deferred rows
33
- // ---------------------------------------------------------------------------
34
-
35
- const pendingChunks = new Map(); // rowId → { promise, resolve }
36
- const lazyCache = new Map(); // rowId → React.lazy component (memoized)
37
-
38
- /** Clear all pending lazy refs. Call at the start of each navigation. */
39
- export function clearPendingChunks() {
40
- pendingChunks.clear();
41
- lazyCache.clear();
42
- }
43
-
44
- /**
45
- * Called when a deferred model row arrives during streaming.
46
- * Resolves the pending lazy component so React re-renders the Suspense boundary.
47
- *
48
- * @param {number} rowId
49
- * @param {*} element - the React element tree built from the deferred row
50
- */
51
- export function resolvePendingChunk(rowId, element) {
52
- const chunk = pendingChunks.get(rowId);
53
- if (chunk) {
54
- chunk.resolve(element);
55
- pendingChunks.delete(rowId);
56
- }
57
- }
58
-
59
- function createLazyForPending(rowId) {
60
- if (lazyCache.has(rowId)) return lazyCache.get(rowId);
61
-
62
- let resolve;
63
- const promise = new Promise((r) => { resolve = r; });
64
- pendingChunks.set(rowId, { promise, resolve });
65
-
66
- // React.lazy expects { default: ComponentType }. We wrap the element in a function component.
67
- const LazyComp = lazy(() => promise.then((el) => ({ default: () => el })));
68
- lazyCache.set(rowId, LazyComp);
69
- return LazyComp;
70
- }
71
-
72
- // ---------------------------------------------------------------------------
73
- // Row parsing
74
- // ---------------------------------------------------------------------------
75
-
76
- /**
77
- * Parse a single Flight wire format line into { id, row }.
78
- * Returns null for blank or malformed lines.
79
- *
80
- * @param {string} line
81
- * @returns {{ id: number, row: object } | null}
82
- */
83
- export function parseLine(line) {
84
- if (!line.trim()) return null;
85
-
86
- const colonIdx = line.indexOf(":");
87
- if (colonIdx === -1) return null;
88
- if (colonIdx === 0) return null; // hint row (":H…") — a preload signal, no id
89
-
90
- const id = parseInt(line.slice(0, colonIdx), 16);
91
- const rest = line.slice(colonIdx + 1);
92
-
93
- try {
94
- if (rest.startsWith("I")) {
95
- const [moduleId, exportName] = JSON.parse(rest.slice(1));
96
- return { id, row: { kind: "import", moduleId, exportName } };
97
- }
98
-
99
- if (rest.startsWith("E")) {
100
- const errorData = JSON.parse(rest.slice(1));
101
- const message = typeof errorData === "string"
102
- ? errorData
103
- : (errorData.message || String(errorData));
104
- return { id, row: { kind: "error", message } };
105
- }
106
-
107
- return { id, row: { kind: "model", value: JSON.parse(rest) } };
108
- } catch (e) {
109
- console.warn("[flight-client] Skipping malformed row:", line, e);
110
- return null;
111
- }
112
- }
113
-
114
- // ---------------------------------------------------------------------------
115
- // Incremental row parser (bytes in, rows out)
116
- // ---------------------------------------------------------------------------
117
-
118
- const COLON = 0x3a;
119
- const COMMA = 0x2c;
120
- const NEWLINE = 0x0a;
121
- const TAG_T = 0x54;
122
- const HEX = /^[0-9a-fA-F]+$/;
123
-
124
- const utf8 = new TextDecoder("utf-8");
125
-
126
- /**
127
- * An incremental Flight row parser. Feed it the payload's bytes in any number
128
- * of chunks — split anywhere, including inside a header, a length or a UTF-8
129
- * code point — and it calls `onRow({ id, row })` once per complete row.
130
- *
131
- * Text rows are framed by their BYTE length, so the parser works on bytes,
132
- * never on decoded strings: a string's `length` counts UTF-16 units and would
133
- * misread any non-ASCII text.
134
- *
135
- * `end()` must be called when the input is complete. A truncated text row or
136
- * a malformed length throws instead of yielding a partial payload.
137
- *
138
- * @param {(parsed: { id: number, row: object }) => void} onRow
139
- * @returns {{ push(bytes: Uint8Array): void, end(): void }}
140
- */
141
- export function createRowParser(onRow) {
142
- // A growable byte buffer: `buf[pos, len)` is what is not parsed yet. It
143
- // grows by doubling and is compacted only when full, so a large row arriving
144
- // in many small chunks costs linear time, not a copy per chunk.
145
- let buf = new Uint8Array(4096);
146
- let len = 0;
147
- let pos = 0;
148
- // How far the newline search for the row at `pos` already got, so a long
149
- // model row is not re-scanned from its start on every chunk.
150
- let scanned = 0;
151
-
152
- const append = (bytes) => {
153
- if (len + bytes.length > buf.length) {
154
- const rest = len - pos;
155
- if (rest + bytes.length <= buf.length) {
156
- buf.copyWithin(0, pos, len);
157
- } else {
158
- let capacity = buf.length * 2;
159
- while (capacity < rest + bytes.length) capacity *= 2;
160
- const next = new Uint8Array(capacity);
161
- next.set(buf.subarray(pos, len));
162
- buf = next;
163
- }
164
- scanned = Math.max(0, scanned - pos);
165
- len = rest;
166
- pos = 0;
167
- }
168
- buf.set(bytes, len);
169
- len += bytes.length;
170
- };
171
-
172
- const advance = (to) => {
173
- pos = to;
174
- scanned = 0;
175
- };
176
-
177
- // Parses one row starting at `pos`. Returns false when it needs more bytes.
178
- const step = () => {
179
- const view = buf.subarray(0, len);
180
- const colon = view.indexOf(COLON, pos);
181
-
182
- // A line with no colon before its end is not a row (a stray line from a
183
- // proxy, say): skip it rather than let it swallow the row after it.
184
- const headerEnd = colon === -1 ? len : colon;
185
- const stray = view.subarray(pos, headerEnd).indexOf(NEWLINE);
186
- if (stray !== -1) {
187
- advance(pos + stray + 1);
188
- return true;
189
- }
190
- if (colon === -1 || colon + 1 >= len) return false;
191
-
192
- const header = utf8.decode(view.subarray(pos, colon)).trim();
193
-
194
- if (HEX.test(header) && view[colon + 1] === TAG_T) {
195
- const comma = view.indexOf(COMMA, colon + 2);
196
- if (comma === -1) return false;
197
- const lengthHex = utf8.decode(view.subarray(colon + 2, comma));
198
- if (!HEX.test(lengthHex)) {
199
- throw new Error(`[flight-client] Malformed text row length "${lengthHex}" for row ${header}`);
200
- }
201
- const length = parseInt(lengthHex, 16);
202
- const start = comma + 1;
203
- if (start + length > len) return false;
204
- onRow({ id: parseInt(header, 16), row: { kind: "text", value: utf8.decode(view.subarray(start, start + length)) } });
205
- advance(start + length);
206
- return true;
207
- }
208
-
209
- const newline = view.indexOf(NEWLINE, Math.max(colon + 1, scanned));
210
- if (newline === -1) {
211
- scanned = len;
212
- return false;
213
- }
214
- const parsed = parseLine(utf8.decode(view.subarray(pos, newline)));
215
- if (parsed) onRow(parsed);
216
- advance(newline + 1);
217
- return true;
218
- };
219
-
220
- return {
221
- push(bytes) {
222
- append(bytes);
223
- while (pos < len) {
224
- // Blank separators between rows carry nothing.
225
- while (pos < len && (buf[pos] === NEWLINE || buf[pos] === 0x0d)) advance(pos + 1);
226
- if (pos >= len || !step()) break;
227
- }
228
- },
229
-
230
- end() {
231
- const rest = utf8.decode(buf.subarray(pos, len));
232
- if (!rest.trim()) return;
233
- // A last model/import/error row without its trailing newline is complete
234
- // as far as JSON can tell; a cut-off text row or header is not.
235
- const colon = rest.indexOf(":");
236
- const header = colon === -1 ? "" : rest.slice(0, colon).trim();
237
- if (colon !== -1 && HEX.test(header) && rest[colon + 1] === "T") {
238
- throw new Error(`[flight-client] Truncated Flight payload: text row ${header} is incomplete`);
239
- }
240
- const parsed = colon === -1 ? null : parseLine(rest);
241
- if (!parsed) throw new Error(`[flight-client] Truncated Flight payload: "${rest.slice(0, 40)}"`);
242
- onRow(parsed);
243
- advance(len);
244
- },
245
- };
246
- }
247
-
248
- // ---------------------------------------------------------------------------
249
- // Tree building
250
- // ---------------------------------------------------------------------------
251
-
252
- /**
253
- * Build a React element tree from a fully-populated rows Map.
254
- *
255
- * @param {Map} rows - id → { kind, ... } rows
256
- * @param {Object} moduleRegistry - { [moduleId]: { [exportName]: Component } }
257
- * @returns React element tree
258
- */
259
- export function buildTreeFromRows(rows, moduleRegistry) {
260
- const root = rows.get(0);
261
- if (!root) throw new Error("[flight-client] No root row (id=0) found in payload");
262
- if (root.kind === "error") throw new Error(`[ruact] Server error: ${root.message}`);
263
- if (root.kind !== "model") throw new Error("[flight-client] Root row is not a model row");
264
- return buildTree(root.value, rows, moduleRegistry);
265
- }
266
-
267
- /**
268
- * Build a React element tree from a single row value.
269
- * Used when resolving a deferred (Suspense) row that has arrived via streaming.
270
- *
271
- * @param {*} value
272
- * @param {Map} rows
273
- * @param {Object} moduleRegistry
274
- */
275
- export function buildTree(value, rows, moduleRegistry) {
276
- return _buildTree(value, rows, moduleRegistry);
277
- }
278
-
279
- /**
280
- * Parse a Flight payload string and return a React element tree.
281
- * Convenience wrapper — used for the initial (non-streaming) page load.
282
- *
283
- * @param {string} payload
284
- * @param {Object} moduleRegistry
285
- */
286
- export function createFromFlightPayload(payload, moduleRegistry) {
287
- const rows = new Map();
288
- const parser = createRowParser(({ id, row }) => rows.set(id, row));
289
- parser.push(new TextEncoder().encode(payload));
290
- parser.end();
291
- return buildTreeFromRows(rows, moduleRegistry);
292
- }
293
-
294
- // ---------------------------------------------------------------------------
295
- // Internal helpers
296
- // ---------------------------------------------------------------------------
297
-
298
- function _buildTree(value, rows, moduleRegistry) {
299
- if (value === null || value === undefined) return value;
300
-
301
- // --- Strings with special $ prefixes ---
302
- if (typeof value === "string") {
303
- if (value.startsWith("$$")) return value.slice(1); // escaped $
304
- if (value === "$undefined") return undefined;
305
- if (value === "$NaN") return NaN;
306
- if (value === "$Infinity") return Infinity;
307
- if (value === "$-Infinity") return -Infinity;
308
- if (value === "$-0") return -0;
309
- if (value.startsWith("$T")) {
310
- const refId = parseInt(value.slice(2), 16);
311
- const row = rows.get(refId);
312
- if (!row || row.kind !== "text") {
313
- throw new Error(`[flight-client] Text row ${value.slice(2)} is referenced but was never received`);
314
- }
315
- return row.value;
316
- }
317
- // Story 17-0c — the serializer emits Ruby Time/DateTime as "$D<ISO 8601>"
318
- // and integers outside JS's safe range as "$n<decimal>". The component
319
- // gets the type, not the marker. (A literal string starting with "$" was
320
- // escaped to "$$…" on the server and is handled above.)
321
- if (value.startsWith("$D")) return new Date(value.slice(2));
322
- // Guarded: a malformed marker must not throw and take the page down.
323
- if (value.startsWith("$n") && /^-?\d+$/.test(value.slice(2))) return BigInt(value.slice(2));
324
- if (value.startsWith("$L")) {
325
- const refId = parseInt(value.slice(2), 16);
326
- const row = rows.get(refId);
327
-
328
- if (!row) {
329
- // Row hasn't arrived yet — create a lazy component that suspends until it does
330
- return createLazyForPending(refId);
331
- }
332
- if (row.kind === "error") {
333
- throw new Error(`[ruact] Server error: ${row.message}`);
334
- }
335
- if (row.kind === "import") {
336
- const mod = moduleRegistry[row.moduleId];
337
- if (!mod) throw new Error(`[flight-client] Module not registered: ${row.moduleId}`);
338
- const component = mod[row.exportName];
339
- if (!component) throw new Error(`[flight-client] Export "${row.exportName}" not found in ${row.moduleId}`);
340
- return component;
341
- }
342
- // Model row — deferred content already arrived (non-streaming path).
343
- // Wrap in a function component so it can be used as a type in createElement.
344
- const content = _buildTree(row.value, rows, moduleRegistry);
345
- return () => content;
346
- }
347
- return value;
348
- }
349
-
350
- // --- Arrays ---
351
- if (Array.isArray(value)) {
352
- // React element tuple: ["$", type, key, props]
353
- if (value[0] === "$") {
354
- const [, rawType, key, rawProps] = value;
355
- const type = resolveType(rawType, rows, moduleRegistry);
356
- const props = buildProps(rawProps, rows, moduleRegistry);
357
- if (key != null) props.key = key;
358
- return createElement(type, props);
359
- }
360
-
361
- // Plain array — fragment children OR a data array sitting in a prop.
362
- //
363
- // Arity is preserved. This walker has no positional information: it cannot
364
- // tell a `children` array from a list prop, so any shape change made here
365
- // is made to both. It used to collapse single-element arrays (`items[0]`
366
- // when `length === 1`), which silently turned a one-row list prop into the
367
- // row itself — issue #62.
368
- //
369
- // Nothing needs to compensate: the server already collapses a lone child
370
- // before it reaches the wire (`html_converter.rb:188`, and the Suspense
371
- // path at `:202`), and React renders an array of children fine. If a
372
- // client-side collapse is ever needed, it belongs in `buildProps` under
373
- // `key === "children"`, where the position IS known.
374
- return value.map((v) => _buildTree(v, rows, moduleRegistry));
375
- }
376
-
377
- // --- Plain objects ---
378
- if (typeof value === "object") {
379
- return Object.fromEntries(
380
- Object.entries(value).map(([k, v]) => [k, _buildTree(v, rows, moduleRegistry)])
381
- );
382
- }
383
-
384
- return value;
385
- }
386
-
387
- function resolveType(rawType, rows, moduleRegistry) {
388
- if (typeof rawType === "string") {
389
- if (rawType === "$SS") return Suspense; // React.Suspense
390
- if (rawType.startsWith("$L")) return _buildTree(rawType, rows, moduleRegistry); // lazy / import
391
- return rawType;
392
- }
393
- return rawType;
394
- }
395
-
396
- function buildProps(rawProps, rows, moduleRegistry) {
397
- if (!rawProps) return {};
398
- const props = {};
399
- for (const [key, val] of Object.entries(rawProps)) {
400
- if (key === "children") {
401
- const children = _buildTree(val, rows, moduleRegistry);
402
- if (children !== undefined) props.children = children;
403
- } else {
404
- props[key] = _buildTree(val, rows, moduleRegistry);
405
- }
406
- }
407
- return props;
408
- }