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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -1
  3. data/lib/generators/ruact/install/install_generator.rb +2 -1
  4. data/lib/generators/ruact/install/templates/AGENTS.md.tt +2 -2
  5. data/lib/ruact/component_attributes.rb +91 -0
  6. data/lib/ruact/configuration.rb +28 -0
  7. data/lib/ruact/controller/format_negotiation.rb +83 -0
  8. data/lib/ruact/controller/page_rendering.rb +1 -1
  9. data/lib/ruact/controller.rb +7 -0
  10. data/lib/ruact/erb_preprocessor.rb +1 -47
  11. data/lib/ruact/flight/js_date.rb +22 -0
  12. data/lib/ruact/flight/renderer.rb +8 -3
  13. data/lib/ruact/flight/serializer.rb +10 -4
  14. data/lib/ruact/flight/utf8.rb +36 -0
  15. data/lib/ruact/flight.rb +2 -0
  16. data/lib/ruact/redirect_path.rb +16 -0
  17. data/lib/ruact/server.rb +17 -7
  18. data/lib/ruact/server_functions/action_assigns.rb +52 -0
  19. data/lib/ruact/server_functions/bucket_two_payload.rb +1 -1
  20. data/lib/ruact/testing/component_query.rb +23 -1
  21. data/lib/ruact/testing/flight_extractor.rb +6 -1
  22. data/lib/ruact/version.rb +1 -1
  23. data/lib/ruact/view_helper.rb +43 -5
  24. data/lib/ruact.rb +1 -0
  25. data/vendor/javascript/ruact-server-functions-runtime/index.js +19 -10
  26. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +7 -0
  27. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +12 -0
  28. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +178 -0
  29. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +84 -0
  30. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +199 -9
  31. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +19 -23
  32. 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 = 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);
@@ -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 createElement(type, props);
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
- parseLine,
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
- document.addEventListener("click", handleClick, { capture: true });
37
- document.addEventListener("submit", handleSubmit, { capture: true });
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, { capture: true });
60
- document.removeEventListener("submit", handleSubmit, { capture: true });
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 processLine = (line) => {
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
- const reader = response.body.getReader();
610
- const decoder = new TextDecoder();
611
- let buffer = "";
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.14
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-02 00:00:00.000000000 Z
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