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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -1
- data/lib/generators/ruact/install/install_generator.rb +113 -2
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +1 -1
- data/lib/generators/ruact/install/templates/package.json.tt +4 -4
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +39 -12
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +4 -3
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +8 -8
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +8 -8
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +2 -2
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +1 -1
- data/lib/ruact/configuration.rb +6 -4
- data/lib/ruact/controller/format_negotiation.rb +83 -0
- data/lib/ruact/controller/page_rendering.rb +1 -1
- data/lib/ruact/controller.rb +7 -0
- data/lib/ruact/flight/js_date.rb +22 -0
- data/lib/ruact/flight/renderer.rb +8 -3
- data/lib/ruact/flight/serializer.rb +10 -4
- data/lib/ruact/flight/utf8.rb +36 -0
- data/lib/ruact/flight.rb +2 -0
- data/lib/ruact/redirect_path.rb +16 -0
- data/lib/ruact/routing.rb +24 -6
- data/lib/ruact/server.rb +6 -0
- data/lib/ruact/testing/component_query.rb +23 -1
- data/lib/ruact/testing/flight_extractor.rb +6 -1
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +43 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.js +19 -10
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +7 -0
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +12 -0
- data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +131 -0
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +84 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +167 -5
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +10 -19
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +1 -1
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +3 -3
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +3 -3
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +4 -4
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +4 -4
- 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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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 `
|
|
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 {
|
|
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 ?? [] :
|
|
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 {
|
|
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 ?? [] :
|
|
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
|
|
57
|
-
// typed from the declared `(q:)` kwarg (Story 13.4). The
|
|
58
|
-
//
|
|
59
|
-
export const
|
|
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
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
export const
|
|
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.
|
|
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-
|
|
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
|