ruact 0.0.13 → 0.0.15

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -1
  3. data/lib/generators/ruact/install/install_generator.rb +113 -2
  4. data/lib/generators/ruact/install/templates/Procfile.dev.tt +1 -1
  5. data/lib/generators/ruact/install/templates/package.json.tt +4 -4
  6. data/lib/generators/ruact/scaffold/scaffold_generator.rb +39 -12
  7. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +4 -3
  8. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +8 -8
  9. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +8 -8
  10. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +2 -2
  11. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +1 -1
  12. data/lib/ruact/configuration.rb +6 -4
  13. data/lib/ruact/controller/format_negotiation.rb +83 -0
  14. data/lib/ruact/controller/page_rendering.rb +1 -1
  15. data/lib/ruact/controller.rb +7 -0
  16. data/lib/ruact/flight/js_date.rb +22 -0
  17. data/lib/ruact/flight/renderer.rb +8 -3
  18. data/lib/ruact/flight/serializer.rb +10 -4
  19. data/lib/ruact/flight/utf8.rb +36 -0
  20. data/lib/ruact/flight.rb +2 -0
  21. data/lib/ruact/redirect_path.rb +16 -0
  22. data/lib/ruact/routing.rb +24 -6
  23. data/lib/ruact/server.rb +6 -0
  24. data/lib/ruact/testing/component_query.rb +23 -1
  25. data/lib/ruact/testing/flight_extractor.rb +6 -1
  26. data/lib/ruact/version.rb +1 -1
  27. data/lib/ruact/view_helper.rb +43 -5
  28. data/vendor/javascript/ruact-server-functions-runtime/index.js +19 -10
  29. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +7 -0
  30. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +12 -0
  31. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +131 -0
  32. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +84 -0
  33. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +167 -5
  34. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +10 -19
  35. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +1 -1
  36. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +3 -3
  37. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +3 -3
  38. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +4 -4
  39. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +4 -4
  40. metadata +6 -2
@@ -36,6 +36,7 @@ import path from "node:path";
36
36
  import {
37
37
  buildTree,
38
38
  createFromFlightPayload,
39
+ createRowParser,
39
40
  clearPendingChunks,
40
41
  } from "./runtime/flight-client.js";
41
42
 
@@ -319,3 +320,133 @@ describe("Story 17.0a — the contract, driven by a gem-produced fixture", () =>
319
320
  expect(tree.props.posts).toEqual([{ id: 1, title: "$5 plan" }]);
320
321
  });
321
322
  });
323
+
324
+ // Story 17-0d — every string of 1024+ bytes travels as a `T` row framed by its
325
+ // BYTE length, with no trailing newline. The decoder split on "\n" and so lost
326
+ // the root row: one long post body blanked the whole page. The fixtures are
327
+ // what the Ruby serializer emits (spec/ruact/flight/text_framing_fixtures_spec.rb
328
+ // writes and guards them).
329
+ describe("text rows (Story 17-0d)", () => {
330
+ const FIXTURES = path.join(import.meta.dirname, "../../../spec/fixtures/flight");
331
+ const read = (name) => fs.readFileSync(path.join(FIXTURES, name));
332
+ const expected = JSON.parse(read("text_framing_expected.json").toString("utf8"));
333
+
334
+ const rowsOf = (chunks) => {
335
+ const rows = new Map();
336
+ const parser = createRowParser(({ id, row }) => rows.set(id, row));
337
+ for (const chunk of chunks) parser.push(chunk);
338
+ parser.end();
339
+ return rows;
340
+ };
341
+
342
+ it("decodes every value the serializer framed, byte for byte", () => {
343
+ const tree = createFromFlightPayload(read("text_framing.txt").toString("utf8"), MODULE_REGISTRY);
344
+ expect(tree).toEqual(expected);
345
+ });
346
+
347
+ it("puts long text inside Suspense content where the deferred row points", () => {
348
+ const tree = createFromFlightPayload(read("text_framing_suspense.txt").toString("utf8"), MODULE_REGISTRY);
349
+ const deferred = tree.props.children.type; // the already-arrived deferred row, wrapped
350
+ expect(deferred().props.children).toBe(expected.multibyte);
351
+ });
352
+
353
+ it("does not depend on where the network splits the bytes", () => {
354
+ const bytes = new Uint8Array(read("text_framing.txt"));
355
+ const whole = rowsOf([bytes]);
356
+
357
+ // Every single split point: inside ids, `T` lengths, text bodies and
358
+ // multibyte code points, and between rows.
359
+ for (let cut = 1; cut < bytes.length; cut += 1) {
360
+ const split = rowsOf([bytes.subarray(0, cut), bytes.subarray(cut)]);
361
+ expect(split).toEqual(whole);
362
+ }
363
+
364
+ // And one byte at a time.
365
+ expect(rowsOf([...bytes].map((b) => Uint8Array.of(b)))).toEqual(whole);
366
+ });
367
+
368
+ it("refuses a truncated text row instead of rendering part of the page", () => {
369
+ const bytes = new Uint8Array(read("text_framing.txt"));
370
+ const firstText = Buffer.from(bytes).indexOf(":T") + 10;
371
+ expect(() => rowsOf([bytes.subarray(0, firstText)])).toThrow(/Truncated Flight payload/);
372
+ });
373
+
374
+ it("refuses a malformed text length", () => {
375
+ const enc = new TextEncoder();
376
+ expect(() => rowsOf([enc.encode("1:Tzz,abc0:\"$T1\"\n")])).toThrow(/Malformed text row length/);
377
+ });
378
+
379
+ it("names a text reference whose row never arrived", () => {
380
+ expect(() => createFromFlightPayload('0:{"body":"$T7"}\n', MODULE_REGISTRY))
381
+ .toThrow(/Text row 7 is referenced but was never received/);
382
+ });
383
+
384
+ it("skips a stray line without a colon instead of losing the row after it", () => {
385
+ const rows = rowsOf([new TextEncoder().encode('garbage\n0:{"a":1}\n')]);
386
+ expect(rows.get(0)).toEqual({ kind: "model", value: { a: 1 } });
387
+ });
388
+
389
+ it("reads a large row arriving in small chunks in linear time", () => {
390
+ const big = JSON.stringify({ items: Array.from({ length: 60000 }, (_, i) => ({ id: i, name: `item ${i}` })) });
391
+ const bytes = new TextEncoder().encode(`0:${big}\n`);
392
+ const chunks = [];
393
+ for (let i = 0; i < bytes.length; i += 4096) chunks.push(bytes.subarray(i, i + 4096));
394
+ const started = performance.now();
395
+ const rows = rowsOf(chunks);
396
+ expect(rows.get(0).value.items).toHaveLength(60000);
397
+ // ~2.3 MB in 4 KB chunks; quadratic re-copying took seconds.
398
+ expect(performance.now() - started).toBeLessThan(1000);
399
+ });
400
+
401
+ it("still reads a last row that lost its trailing newline", () => {
402
+ const rows = rowsOf([new TextEncoder().encode('0:{"a":1}')]);
403
+ expect(rows.get(0)).toEqual({ kind: "model", value: { a: 1 } });
404
+ });
405
+ });
406
+
407
+ // Story 17-0c — the serializer emits Ruby Time/DateTime as "$D<ISO>" and
408
+ // integers beyond ±(2^53 − 1) as "$n<decimal>". Components got the marker
409
+ // strings. The fixture is the Ruby serializer's own output
410
+ // (spec/ruact/flight/scalar_fixtures_spec.rb writes and guards it).
411
+ describe("dates and big integers (Story 17-0c)", () => {
412
+ const FIXTURES = path.join(import.meta.dirname, "../../../spec/fixtures/flight");
413
+ const payload = fs.readFileSync(path.join(FIXTURES, "scalar_round_trip.txt"), "utf8");
414
+ const expected = JSON.parse(fs.readFileSync(path.join(FIXTURES, "scalar_round_trip_expected.json"), "utf8"));
415
+
416
+ // Expected JSON tags what JSON cannot hold: { date: ISO } and { bigint: "…" }.
417
+ const revive = (value) => {
418
+ if (Array.isArray(value)) return value.map(revive);
419
+ if (value && typeof value === "object") {
420
+ if ("date" in value) return new Date(value.date);
421
+ if ("bigint" in value) return BigInt(value.bigint);
422
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, revive(v)]));
423
+ }
424
+ return value;
425
+ };
426
+
427
+ it("rebuilds every value with its JS type, nested ones included", () => {
428
+ const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
429
+ expect(tree).toEqual(revive(expected));
430
+ });
431
+
432
+ it("gives a Date the same instant, offset applied, to the millisecond", () => {
433
+ const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
434
+ expect(tree.time).toBeInstanceOf(Date);
435
+ expect(tree.time.toISOString()).toBe("2026-09-08T12:30:45.123Z");
436
+ expect(tree.zoned.toISOString()).toBe("2026-09-08T12:30:00.000Z");
437
+ });
438
+
439
+ it("keeps safe integers as numbers and only the unsafe ones as BigInt", () => {
440
+ const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
441
+ expect(typeof tree.max_safe).toBe("number");
442
+ expect(typeof tree.min_safe).toBe("number");
443
+ expect(typeof tree.big).toBe("bigint");
444
+ expect(tree.big).toBe(9007199254740993n);
445
+ expect(tree.negative_big).toBe(-18446744073709551616n);
446
+ });
447
+
448
+ it("leaves a literal string that starts with $D a string", () => {
449
+ const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
450
+ expect(tree.literal).toBe("$D2026-01-01 is a string");
451
+ });
452
+ });
@@ -11,6 +11,8 @@
11
11
  // classifier is never a silent no-op; and a Flight response still renders.
12
12
 
13
13
  import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
14
16
  import { setupRouter, teardownRouter } from "./runtime/ruact-router.js";
15
17
 
16
18
  const ORIGIN = "http://localhost:3000";
@@ -431,3 +433,85 @@ describe("ruact-router — the navigation boundary (Story 17.0f)", () => {
431
433
  });
432
434
  });
433
435
  });
436
+
437
+ // Story 17-0d — a navigation reads the same byte parser as the initial load.
438
+ // The stream is cut inside a `T` length, inside a multibyte code point and
439
+ // inside a text body; the page must still render with the exact text.
440
+ describe("ruact-router — long text in a streamed navigation (Story 17-0d)", () => {
441
+ const FIXTURES = path.join(import.meta.dirname, "../../../spec/fixtures/flight");
442
+ let dom;
443
+ let onNavigate;
444
+ let onError;
445
+
446
+ beforeEach(() => {
447
+ dom = installDom();
448
+ onNavigate = vi.fn();
449
+ onError = vi.fn();
450
+ globalThis.fetch = vi.fn();
451
+ vi.spyOn(console, "error").mockImplementation(() => {});
452
+ setupRouter({ onNavigate, moduleRegistry: {}, onError });
453
+ });
454
+
455
+ afterEach(() => {
456
+ teardownRouter();
457
+ vi.restoreAllMocks();
458
+ });
459
+
460
+ it("renders every framed string, whatever the chunk boundaries", async () => {
461
+ const bytes = new Uint8Array(fs.readFileSync(path.join(FIXTURES, "text_framing.txt")));
462
+ const expected = JSON.parse(fs.readFileSync(path.join(FIXTURES, "text_framing_expected.json"), "utf8"));
463
+ const firstLength = Buffer.from(bytes).indexOf(":T") + 3; // inside the first hex length
464
+ const multibyte = Buffer.from(bytes).indexOf(Buffer.from("ç")) + 1; // between the two bytes of "ç"
465
+ const cuts = [0, firstLength, multibyte, multibyte + 700, bytes.length];
466
+ const body = new ReadableStream({
467
+ start(controller) {
468
+ for (let i = 0; i < cuts.length - 1; i += 1) controller.enqueue(bytes.slice(cuts[i], cuts[i + 1]));
469
+ controller.close();
470
+ },
471
+ });
472
+ const headers = new Headers({ "content-type": "text/x-component" });
473
+ fetch.mockResolvedValue({ ok: true, status: 200, statusText: "200", url: `${ORIGIN}/posts`, headers, body });
474
+
475
+ click(dom.listeners, "/posts");
476
+ await settle();
477
+
478
+ expect(onError).not.toHaveBeenCalled();
479
+ expect(onNavigate).toHaveBeenCalledTimes(1);
480
+ expect(onNavigate.mock.calls[0][0]).toEqual(expected);
481
+ });
482
+ });
483
+
484
+ // Story 17-0c — a navigation decodes dates and big integers like the first load.
485
+ describe("ruact-router — dates and big integers in a navigation (Story 17-0c)", () => {
486
+ const FIXTURES = path.join(import.meta.dirname, "../../../spec/fixtures/flight");
487
+ let dom;
488
+ let onNavigate;
489
+
490
+ beforeEach(() => {
491
+ dom = installDom();
492
+ onNavigate = vi.fn();
493
+ globalThis.fetch = vi.fn();
494
+ vi.spyOn(console, "error").mockImplementation(() => {});
495
+ setupRouter({ onNavigate, moduleRegistry: {}, onError: vi.fn() });
496
+ });
497
+
498
+ afterEach(() => {
499
+ teardownRouter();
500
+ vi.restoreAllMocks();
501
+ });
502
+
503
+ it("hands the component a Date and a BigInt", async () => {
504
+ const body = fs.readFileSync(path.join(FIXTURES, "scalar_round_trip.txt"), "utf8");
505
+ fetch.mockResolvedValue(respond({ body, url: `${ORIGIN}/events` }));
506
+
507
+ click(dom.listeners, "/events");
508
+ await settle();
509
+
510
+ const tree = onNavigate.mock.calls[0][0];
511
+ expect(tree.time).toBeInstanceOf(Date);
512
+ expect(tree.time.toISOString()).toBe("2026-09-08T12:30:45.123Z");
513
+ expect(tree.big).toBe(9007199254740993n);
514
+ expect(tree.nested.ids).toEqual([1152921504606846976n, 7]);
515
+ });
516
+ });
517
+
@@ -4,6 +4,19 @@
4
4
  * Handles the subset we emit from the Ruby server:
5
5
  * - Model rows: <hex_id>:<json>\n
6
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 "$$…".
7
20
  *
8
21
  * Returns a React element tree by recursively converting
9
22
  * ["$", type, key, props] tuples into React.createElement calls.
@@ -72,6 +85,7 @@ export function parseLine(line) {
72
85
 
73
86
  const colonIdx = line.indexOf(":");
74
87
  if (colonIdx === -1) return null;
88
+ if (colonIdx === 0) return null; // hint row (":H…") — a preload signal, no id
75
89
 
76
90
  const id = parseInt(line.slice(0, colonIdx), 16);
77
91
  const rest = line.slice(colonIdx + 1);
@@ -97,6 +111,140 @@ export function parseLine(line) {
97
111
  }
98
112
  }
99
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
+
100
248
  // ---------------------------------------------------------------------------
101
249
  // Tree building
102
250
  // ---------------------------------------------------------------------------
@@ -136,11 +284,10 @@ export function buildTree(value, rows, moduleRegistry) {
136
284
  * @param {Object} moduleRegistry
137
285
  */
138
286
  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
- }
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();
144
291
  return buildTreeFromRows(rows, moduleRegistry);
145
292
  }
146
293
 
@@ -159,6 +306,21 @@ function _buildTree(value, rows, moduleRegistry) {
159
306
  if (value === "$Infinity") return Infinity;
160
307
  if (value === "$-Infinity") return -Infinity;
161
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));
162
324
  if (value.startsWith("$L")) {
163
325
  const refId = parseInt(value.slice(2), 16);
164
326
  const row = rows.get(refId);
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import {
13
- parseLine,
13
+ createRowParser,
14
14
  buildTreeFromRows,
15
15
  buildTree,
16
16
  resolvePendingChunk,
@@ -564,11 +564,10 @@ async function _processFlightResponse(response, {
564
564
  let initialTreeSet = false;
565
565
  let redirected = false;
566
566
 
567
- const processLine = (line) => {
568
- const parsed = parseLine(line);
569
- if (!parsed) return;
570
-
567
+ const processRow = (parsed) => {
571
568
  rows.set(parsed.id, parsed.row);
569
+ // A text row is data a model row points at; nothing renders on its own.
570
+ if (parsed.row.kind === "text") return;
572
571
 
573
572
  if (!initialTreeSet && rows.has(0)) {
574
573
  const rootRow = rows.get(0);
@@ -606,25 +605,17 @@ async function _processFlightResponse(response, {
606
605
  }
607
606
  };
608
607
 
609
- const reader = response.body.getReader();
610
- const decoder = new TextDecoder();
611
- let buffer = "";
608
+ // The same byte parser the initial load uses: text rows are framed by byte
609
+ // length, so the stream is never decoded line by line.
610
+ const parser = createRowParser(processRow);
611
+ const reader = response.body.getReader();
612
612
 
613
613
  while (true) {
614
614
  const { done, value } = await reader.read();
615
615
  if (done) break;
616
-
617
- buffer += decoder.decode(value, { stream: true });
618
-
619
- let newlineIdx;
620
- while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
621
- processLine(buffer.slice(0, newlineIdx));
622
- buffer = buffer.slice(newlineIdx + 1);
623
- }
616
+ parser.push(value);
624
617
  }
625
-
626
- // Handle any remaining content without a trailing newline
627
- if (buffer.trim()) processLine(buffer);
618
+ parser.end();
628
619
 
629
620
  // Fallback: if row 0 never triggered (shouldn't happen with valid server)
630
621
  if (!initialTreeSet && !redirected && rows.has(0)) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "//": "Story 14.4 (AC5) — type-check the GENERATED design-system-AGNOSTIC `<Model>{List,Form,DeleteDialog}.tsx` fixtures in ISOLATION against an ambient stub (type-tests/scaffold/agnostic/ambient.d.ts) that declares ONLY React + @/.ruact/server-functions and NO @/components/ui/* module. A stray shadcn import would fail to resolve → the typecheck goes red, proving the agnostic default needs zero design-system resolution. Mirrors tsconfig.scaffold.json (the shadcn isolated typecheck), but scoped to the agnostic subdir so the two ambient stubs never merge. Run via `npm run typecheck`.",
3
- "//noImplicitAny": "Relaxed ONLY here: native-element event handler params (e.g. `onChange={(e) => …}`) get no contextual type from the permissive ambient `JSX.IntrinsicElements` (no @types/react / lib:dom). The load-bearing types — PostRow, the typed `search` accessor (FR99), the required `posts` prop, the controlled DeleteDialog contract — are all EXPLICITLY annotated and stay fully checked under `strict`.",
3
+ "//noImplicitAny": "Relaxed ONLY here: native-element event handler params (e.g. `onChange={(e) => …}`) get no contextual type from the permissive ambient `JSX.IntrinsicElements` (no @types/react / lib:dom). The load-bearing types — PostRow, the typed `searchPosts` accessor (FR99), the required `posts` prop, the controlled DeleteDialog contract — are all EXPLICITLY annotated and stay fully checked under `strict`.",
4
4
  "compilerOptions": {
5
5
  "strict": true,
6
6
  "noImplicitAny": false,
@@ -18,7 +18,7 @@
18
18
  // freshly scaffolded app will not resolve these until 10.5 lands; that is
19
19
  // expected (the end-to-end live demo is Story 10.7).
20
20
  import { useState } from "react";
21
- import { search as searchPosts, destroyPost, useQuery } from "@/.ruact/server-functions";
21
+ import { searchPosts, destroyPost, useQuery } from "@/.ruact/server-functions";
22
22
  import { PostDeleteDialog } from "./PostDeleteDialog";
23
23
  import { Badge } from "@/components/ui/badge";
24
24
  import { Button } from "@/components/ui/button";
@@ -194,7 +194,7 @@ function RowActions({ record, onDeleted }: {
194
194
  }
195
195
 
196
196
  export function PostList({
197
- posts = [],
197
+ posts: initialRows = [],
198
198
  emptyLabel = "No posts yet — create one.",
199
199
  }: { posts?: PostRow[]; emptyLabel?: string }) {
200
200
  const [q, setQ] = useState("");
@@ -215,7 +215,7 @@ export function PostList({
215
215
  // the box is idle and we fall back to the server-rendered rows.
216
216
  const { data: searchData, loading: searchLoading } = useQuery<PostRow[]>(searchPosts, { q: q.trim() });
217
217
 
218
- const source = searching ? searchData ?? [] : posts;
218
+ const source = searching ? searchData ?? [] : initialRows;
219
219
  const rows = removedIds.length === 0 ? source : source.filter((row) => !removedIds.includes(row.id));
220
220
  // Always sort a COPY — never mutate the prop/source array.
221
221
  const sortedRows = sort ? [...rows].sort((a, b) => compareRows(a, b, sort)) : rows;
@@ -13,7 +13,7 @@
13
13
  // sort/pagination is Phase-3 territory. (The richer shadcn DataTable styling is
14
14
  // the opt-in `--shadcn` path — Story 14.5.)
15
15
  import { useState } from "react";
16
- import { search as searchPosts, destroyPost, useQuery } from "@/.ruact/server-functions";
16
+ import { searchPosts, destroyPost, useQuery } from "@/.ruact/server-functions";
17
17
  import { PostDeleteDialog } from "./PostDeleteDialog";
18
18
 
19
19
  type PostRow = { id: number; title: string | null; body: string | null; published: boolean | null; views: number | null; published_on: string | null; published_at: string | null; author_id: number | null };
@@ -139,7 +139,7 @@ function RowActions({ record, onDeleted }: {
139
139
  }
140
140
 
141
141
  export function PostList({
142
- posts = [],
142
+ posts: initialRows = [],
143
143
  emptyLabel = "No posts yet — create one.",
144
144
  }: { posts?: PostRow[]; emptyLabel?: string }) {
145
145
  const [q, setQ] = useState("");
@@ -160,7 +160,7 @@ export function PostList({
160
160
  // the box is idle and we fall back to the server-rendered rows.
161
161
  const { data: searchData, loading: searchLoading } = useQuery<PostRow[]>(searchPosts, { q: q.trim() });
162
162
 
163
- const source = searching ? searchData ?? [] : posts;
163
+ const source = searching ? searchData ?? [] : initialRows;
164
164
  const rows = removedIds.length === 0 ? source : source.filter((row) => !removedIds.includes(row.id));
165
165
  // Always sort a COPY — never mutate the prop/source array.
166
166
  const sortedRows = sort ? [...rows].sort((a, b) => compareRows(a, b, sort)) : rows;
@@ -53,10 +53,10 @@ declare module "@/.ruact/server-functions" {
53
53
  // FR88/FR99 wire union — the only param value type a query accessor accepts.
54
54
  type Wire = string | number | boolean | null;
55
55
 
56
- // The codegen exports a generic `search` from `<Plural>Query#search(q:)`,
57
- // typed from the declared `(q:)` kwarg (Story 13.4). The List aliases it
58
- // `search<Plural>`.
59
- export const search: (params: { q: Wire }) => Promise<unknown>;
56
+ // The codegen exports `searchPosts` from `PostsQuery#search_posts(q:)`,
57
+ // typed from the declared `(q:)` kwarg (Story 13.4). The scaffold names the
58
+ // query after the resource: query names share one namespace.
59
+ export const searchPosts: (params: { q: Wire }) => Promise<unknown>;
60
60
 
61
61
  // Action accessors the generated Form imports. Actions are NOT typed by FR99
62
62
  // (only queries are — Story 13.4 decision A), so the accessors take a loose
@@ -39,10 +39,10 @@ declare module "@/.ruact/server-functions" {
39
39
  // FR88/FR99 wire union — the only param value type a query accessor accepts.
40
40
  type Wire = string | number | boolean | null;
41
41
 
42
- // The codegen exports a generic `search` from `<Plural>Query#search(q:)`,
43
- // typed from the declared `(q:)` kwarg (Story 13.4). The List aliases it
44
- // `search<Plural>`.
45
- export const search: (params: { q: Wire }) => Promise<unknown>;
42
+ // The codegen exports `searchPosts` from `PostsQuery#search_posts(q:)` —
43
+ // the scaffold names the query after the resource, because query names
44
+ // share one namespace across every query class.
45
+ export const searchPosts: (params: { q: Wire }) => Promise<unknown>;
46
46
 
47
47
  // Action accessors the generated Form imports (Story 10.3). Actions are NOT
48
48
  // typed by FR99 (only queries are — Story 13.4 decision A), so the accessors
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruact
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.13
4
+ version: 0.0.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Luiz Garcia
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-28 00:00:00.000000000 Z
11
+ date: 2026-10-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri
@@ -76,6 +76,7 @@ files:
76
76
  - lib/ruact/configuration.rb
77
77
  - lib/ruact/controller.rb
78
78
  - lib/ruact/controller/document_rendering.rb
79
+ - lib/ruact/controller/format_negotiation.rb
79
80
  - lib/ruact/controller/page_rendering.rb
80
81
  - lib/ruact/controller/pages.rb
81
82
  - lib/ruact/doctor.rb
@@ -83,11 +84,13 @@ files:
83
84
  - lib/ruact/erb_preprocessor_hook.rb
84
85
  - lib/ruact/errors.rb
85
86
  - lib/ruact/flight.rb
87
+ - lib/ruact/flight/js_date.rb
86
88
  - lib/ruact/flight/react_element.rb
87
89
  - lib/ruact/flight/renderer.rb
88
90
  - lib/ruact/flight/request.rb
89
91
  - lib/ruact/flight/row_emitter.rb
90
92
  - lib/ruact/flight/serializer.rb
93
+ - lib/ruact/flight/utf8.rb
91
94
  - lib/ruact/html_converter.rb
92
95
  - lib/ruact/layout_source.rb
93
96
  - lib/ruact/manifest_resolver.rb
@@ -95,6 +98,7 @@ files:
95
98
  - lib/ruact/packaging.rb
96
99
  - lib/ruact/query.rb
97
100
  - lib/ruact/railtie.rb
101
+ - lib/ruact/redirect_path.rb
98
102
  - lib/ruact/render_context.rb
99
103
  - lib/ruact/render_pipeline.rb
100
104
  - lib/ruact/routing.rb