ruact 0.0.14 → 0.0.16
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 +2 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +2 -2
- data/lib/ruact/component_attributes.rb +91 -0
- data/lib/ruact/configuration.rb +28 -0
- 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/erb_preprocessor.rb +1 -47
- 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/server.rb +17 -7
- data/lib/ruact/server_functions/action_assigns.rb +52 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +1 -1
- 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/lib/ruact.rb +1 -0
- 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 +178 -0
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +84 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +199 -9
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +19 -23
- metadata +8 -2
|
@@ -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.
|
|
@@ -13,7 +26,7 @@
|
|
|
13
26
|
* - "$L{hex}" referencing a missing row → React.lazy() that resolves when the row arrives
|
|
14
27
|
*/
|
|
15
28
|
|
|
16
|
-
import { createElement, Fragment, lazy, Suspense } from "react";
|
|
29
|
+
import { createElement, Fragment, isValidElement, lazy, Suspense } from "react";
|
|
17
30
|
|
|
18
31
|
// ---------------------------------------------------------------------------
|
|
19
32
|
// Pending chunk registry — used for streaming Suspense deferred rows
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -125,7 +273,7 @@ export function buildTreeFromRows(rows, moduleRegistry) {
|
|
|
125
273
|
* @param {Object} moduleRegistry
|
|
126
274
|
*/
|
|
127
275
|
export function buildTree(value, rows, moduleRegistry) {
|
|
128
|
-
return _buildTree(value, rows, moduleRegistry);
|
|
276
|
+
return rootTree(_buildTree(value, rows, moduleRegistry));
|
|
129
277
|
}
|
|
130
278
|
|
|
131
279
|
/**
|
|
@@ -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);
|
|
@@ -179,7 +341,7 @@ function _buildTree(value, rows, moduleRegistry) {
|
|
|
179
341
|
}
|
|
180
342
|
// Model row — deferred content already arrived (non-streaming path).
|
|
181
343
|
// Wrap in a function component so it can be used as a type in createElement.
|
|
182
|
-
const content = _buildTree(row.value, rows, moduleRegistry);
|
|
344
|
+
const content = rootTree(_buildTree(row.value, rows, moduleRegistry));
|
|
183
345
|
return () => content;
|
|
184
346
|
}
|
|
185
347
|
return value;
|
|
@@ -193,7 +355,7 @@ function _buildTree(value, rows, moduleRegistry) {
|
|
|
193
355
|
const type = resolveType(rawType, rows, moduleRegistry);
|
|
194
356
|
const props = buildProps(rawProps, rows, moduleRegistry);
|
|
195
357
|
if (key != null) props.key = key;
|
|
196
|
-
return
|
|
358
|
+
return createWithChildren(type, props);
|
|
197
359
|
}
|
|
198
360
|
|
|
199
361
|
// Plain array — fragment children OR a data array sitting in a prop.
|
|
@@ -222,6 +384,34 @@ function _buildTree(value, rows, moduleRegistry) {
|
|
|
222
384
|
return value;
|
|
223
385
|
}
|
|
224
386
|
|
|
387
|
+
// A server-rendered tree is static: siblings (an <h1> beside a component, the
|
|
388
|
+
// rows of an ERB loop) carry no key on the wire. Handed to React as an ARRAY,
|
|
389
|
+
// they made it log "Each child in a list should have a unique key" — on the
|
|
390
|
+
// Getting Started page itself. Passed as separate arguments, the way JSX
|
|
391
|
+
// compiles `<div><h1/><p/></div>`, they are static children React does not
|
|
392
|
+
// key-check, and nothing is invented: a key the template gave still wins, and
|
|
393
|
+
// reconciliation is the one React does for JSX. Two or more only — a single
|
|
394
|
+
// child or an explicit one-element array keeps its shape (Story 17-0a).
|
|
395
|
+
function createWithChildren(type, props) {
|
|
396
|
+
const { children } = props;
|
|
397
|
+
if (!Array.isArray(children) || children.length < 2 || !children.some(isValidElement)) {
|
|
398
|
+
return createElement(type, props); // a data array passed as `children` stays a plain array
|
|
399
|
+
}
|
|
400
|
+
const rest = { ...props };
|
|
401
|
+
delete rest.children;
|
|
402
|
+
return createElement(type, rest, ...children);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// The page's root — or a Suspense boundary's deferred content — when it is
|
|
406
|
+
// several siblings: a Fragment of them, for the same reason as above (React
|
|
407
|
+
// logged the warning against <App>).
|
|
408
|
+
function rootTree(tree) {
|
|
409
|
+
if (Array.isArray(tree) && tree.length >= 2 && tree.some(isValidElement)) {
|
|
410
|
+
return createElement(Fragment, null, ...tree);
|
|
411
|
+
}
|
|
412
|
+
return tree;
|
|
413
|
+
}
|
|
414
|
+
|
|
225
415
|
function resolveType(rawType, rows, moduleRegistry) {
|
|
226
416
|
if (typeof rawType === "string") {
|
|
227
417
|
if (rawType === "$SS") return Suspense; // React.Suspense
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
|
-
|
|
13
|
+
createRowParser,
|
|
14
14
|
buildTreeFromRows,
|
|
15
15
|
buildTree,
|
|
16
16
|
resolvePendingChunk,
|
|
@@ -33,8 +33,13 @@ export function setupRouter({ onNavigate, moduleRegistry, onError = null }) {
|
|
|
33
33
|
_moduleRegistry = moduleRegistry;
|
|
34
34
|
_onError = onError;
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
// Bubble phase, on the document: the app's own handlers (React's, which
|
|
37
|
+
// listen on the root container, and any other) run first, so an
|
|
38
|
+
// `onSubmit`/`onClick` that calls preventDefault() keeps the event. In the
|
|
39
|
+
// capture phase the router decided before them: a React form that handles
|
|
40
|
+
// its own submit (the scaffold's) also got a router fetch of its URL.
|
|
41
|
+
document.addEventListener("click", handleClick);
|
|
42
|
+
document.addEventListener("submit", handleSubmit);
|
|
38
43
|
window.addEventListener("popstate", handlePopstate);
|
|
39
44
|
|
|
40
45
|
// Story 8.2 — publish the revalidate handle the runtime helper reads.
|
|
@@ -56,8 +61,8 @@ export function setupRouter({ onNavigate, moduleRegistry, onError = null }) {
|
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
export function teardownRouter() {
|
|
59
|
-
document.removeEventListener("click", handleClick
|
|
60
|
-
document.removeEventListener("submit", handleSubmit
|
|
64
|
+
document.removeEventListener("click", handleClick);
|
|
65
|
+
document.removeEventListener("submit", handleSubmit);
|
|
61
66
|
window.removeEventListener("popstate", handlePopstate);
|
|
62
67
|
_onNavigate = null;
|
|
63
68
|
_moduleRegistry = null;
|
|
@@ -564,11 +569,10 @@ async function _processFlightResponse(response, {
|
|
|
564
569
|
let initialTreeSet = false;
|
|
565
570
|
let redirected = false;
|
|
566
571
|
|
|
567
|
-
const
|
|
568
|
-
const parsed = parseLine(line);
|
|
569
|
-
if (!parsed) return;
|
|
570
|
-
|
|
572
|
+
const processRow = (parsed) => {
|
|
571
573
|
rows.set(parsed.id, parsed.row);
|
|
574
|
+
// A text row is data a model row points at; nothing renders on its own.
|
|
575
|
+
if (parsed.row.kind === "text") return;
|
|
572
576
|
|
|
573
577
|
if (!initialTreeSet && rows.has(0)) {
|
|
574
578
|
const rootRow = rows.get(0);
|
|
@@ -606,25 +610,17 @@ async function _processFlightResponse(response, {
|
|
|
606
610
|
}
|
|
607
611
|
};
|
|
608
612
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
613
|
+
// The same byte parser the initial load uses: text rows are framed by byte
|
|
614
|
+
// length, so the stream is never decoded line by line.
|
|
615
|
+
const parser = createRowParser(processRow);
|
|
616
|
+
const reader = response.body.getReader();
|
|
612
617
|
|
|
613
618
|
while (true) {
|
|
614
619
|
const { done, value } = await reader.read();
|
|
615
620
|
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
|
-
}
|
|
621
|
+
parser.push(value);
|
|
624
622
|
}
|
|
625
|
-
|
|
626
|
-
// Handle any remaining content without a trailing newline
|
|
627
|
-
if (buffer.trim()) processLine(buffer);
|
|
623
|
+
parser.end();
|
|
628
624
|
|
|
629
625
|
// Fallback: if row 0 never triggered (shouldn't happen with valid server)
|
|
630
626
|
if (!initialTreeSet && !redirected && rows.has(0)) {
|
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.16
|
|
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-10-
|
|
11
|
+
date: 2026-10-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: nokogiri
|
|
@@ -72,10 +72,12 @@ files:
|
|
|
72
72
|
- lib/generators/ruact/scaffold/templates/views/show.html.erb.tt
|
|
73
73
|
- lib/ruact.rb
|
|
74
74
|
- lib/ruact/client_manifest.rb
|
|
75
|
+
- lib/ruact/component_attributes.rb
|
|
75
76
|
- lib/ruact/component_contract.rb
|
|
76
77
|
- lib/ruact/configuration.rb
|
|
77
78
|
- lib/ruact/controller.rb
|
|
78
79
|
- lib/ruact/controller/document_rendering.rb
|
|
80
|
+
- lib/ruact/controller/format_negotiation.rb
|
|
79
81
|
- lib/ruact/controller/page_rendering.rb
|
|
80
82
|
- lib/ruact/controller/pages.rb
|
|
81
83
|
- lib/ruact/doctor.rb
|
|
@@ -83,11 +85,13 @@ files:
|
|
|
83
85
|
- lib/ruact/erb_preprocessor_hook.rb
|
|
84
86
|
- lib/ruact/errors.rb
|
|
85
87
|
- lib/ruact/flight.rb
|
|
88
|
+
- lib/ruact/flight/js_date.rb
|
|
86
89
|
- lib/ruact/flight/react_element.rb
|
|
87
90
|
- lib/ruact/flight/renderer.rb
|
|
88
91
|
- lib/ruact/flight/request.rb
|
|
89
92
|
- lib/ruact/flight/row_emitter.rb
|
|
90
93
|
- lib/ruact/flight/serializer.rb
|
|
94
|
+
- lib/ruact/flight/utf8.rb
|
|
91
95
|
- lib/ruact/html_converter.rb
|
|
92
96
|
- lib/ruact/layout_source.rb
|
|
93
97
|
- lib/ruact/manifest_resolver.rb
|
|
@@ -95,12 +99,14 @@ files:
|
|
|
95
99
|
- lib/ruact/packaging.rb
|
|
96
100
|
- lib/ruact/query.rb
|
|
97
101
|
- lib/ruact/railtie.rb
|
|
102
|
+
- lib/ruact/redirect_path.rb
|
|
98
103
|
- lib/ruact/render_context.rb
|
|
99
104
|
- lib/ruact/render_pipeline.rb
|
|
100
105
|
- lib/ruact/routing.rb
|
|
101
106
|
- lib/ruact/serializable.rb
|
|
102
107
|
- lib/ruact/server.rb
|
|
103
108
|
- lib/ruact/server_functions.rb
|
|
109
|
+
- lib/ruact/server_functions/action_assigns.rb
|
|
104
110
|
- lib/ruact/server_functions/backtrace_cleaner.rb
|
|
105
111
|
- lib/ruact/server_functions/bucket_two_payload.rb
|
|
106
112
|
- lib/ruact/server_functions/codegen.rb
|