ruact 0.0.15 → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/lib/generators/ruact/install/install_generator.rb +2 -1
  4. data/lib/generators/ruact/install/templates/AGENTS.md.tt +2 -2
  5. data/lib/ruact/client_manifest.rb +11 -2
  6. data/lib/ruact/component_attributes.rb +91 -0
  7. data/lib/ruact/configuration.rb +28 -0
  8. data/lib/ruact/controller.rb +3 -1
  9. data/lib/ruact/doctor.rb +64 -4
  10. data/lib/ruact/erb_preprocessor.rb +1 -47
  11. data/lib/ruact/flight/renderer.rb +23 -9
  12. data/lib/ruact/flight/request.rb +31 -2
  13. data/lib/ruact/flight/row_emitter.rb +9 -0
  14. data/lib/ruact/flight/serializer.rb +22 -7
  15. data/lib/ruact/render_pipeline.rb +10 -3
  16. data/lib/ruact/server.rb +11 -7
  17. data/lib/ruact/server_functions/action_assigns.rb +52 -0
  18. data/lib/ruact/server_functions/bucket_two_payload.rb +1 -1
  19. data/lib/ruact/testing/component_query.rb +11 -4
  20. data/lib/ruact/testing/flight_wire_parser.rb +2 -2
  21. data/lib/ruact/version.rb +1 -1
  22. data/lib/ruact.rb +1 -0
  23. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +44 -2
  24. data/vendor/javascript/vite-plugin-ruact/flight-conformance.test.mjs +286 -0
  25. data/vendor/javascript/vite-plugin-ruact/index.js +89 -14
  26. data/vendor/javascript/vite-plugin-ruact/package-lock.json +804 -3
  27. data/vendor/javascript/vite-plugin-ruact/package.json +5 -1
  28. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +53 -0
  29. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +46 -17
  30. data/vendor/javascript/vite-plugin-ruact/runtime/flight-modules.js +40 -0
  31. data/vendor/javascript/vite-plugin-ruact/runtime/flight-webpack-globals.js +15 -0
  32. data/vendor/javascript/vite-plugin-ruact/runtime/root-boundary.js +41 -0
  33. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +75 -88
  34. data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +83 -0
  35. data/vendor/javascript/vite-plugin-ruact/runtime/transport.js +41 -0
  36. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/LICENSE +21 -0
  37. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/VERSION.json +5 -0
  38. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.development.js +5473 -0
  39. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.production.js +2126 -0
  40. data/vendor/javascript/vite-plugin-ruact/scripts/vendor-flight-client.mjs +124 -0
  41. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +14 -0
  42. metadata +15 -4
  43. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +0 -452
  44. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +0 -408
@@ -15,10 +15,15 @@ module Ruact
15
15
  class RenderPipeline
16
16
  VALID_MODES = %i[string stream].freeze
17
17
 
18
- def initialize(manifest, controller_path: nil, logger: nil)
18
+ # @param development [Boolean] emit the element slots React's development
19
+ # client reads (see Flight::Serializer#element_tuple). The controller
20
+ # passes {Flight::Renderer.development_default}; everything else gets the
21
+ # production wire.
22
+ def initialize(manifest, controller_path: nil, logger: nil, development: false)
19
23
  @manifest = manifest
20
24
  @controller_path = controller_path
21
25
  @logger = logger
26
+ @development = development
22
27
  end
23
28
 
24
29
  # Render a server component tree to Flight wire format.
@@ -194,7 +199,8 @@ module Ruact
194
199
  Flight::Renderer.each(root_element, @manifest,
195
200
  strict_serialization: Ruact.config.strict_serialization,
196
201
  on_as_json_warning: as_json_warning_callback,
197
- streaming: streaming) { |row| y << row }
202
+ streaming: streaming,
203
+ development: @development) { |row| y << row }
198
204
  end
199
205
  end
200
206
 
@@ -215,7 +221,8 @@ module Ruact
215
221
  Flight::Renderer.each(root_element, @manifest,
216
222
  strict_serialization: strict,
217
223
  on_as_json_warning: warning_cb,
218
- streaming: streaming) { |row| y << row }
224
+ streaming: streaming,
225
+ development: @development) { |row| y << row }
219
226
  end
220
227
  end
221
228
 
data/lib/ruact/server.rb CHANGED
@@ -14,6 +14,7 @@ require_relative "../ruact"
14
14
  require_relative "server_functions/error_rendering"
15
15
  require_relative "server_functions/bucket_two_payload"
16
16
  require_relative "server_functions/name_bridge"
17
+ require_relative "server_functions/action_assigns"
17
18
  require_relative "redirect_path"
18
19
  require_relative "validation_errors_collector"
19
20
 
@@ -69,6 +70,9 @@ module Ruact
69
70
  module Server
70
71
  extend ActiveSupport::Concern
71
72
 
73
+ # A function call returns the action's own ivars, not its callbacks'.
74
+ include Ruact::ServerFunctions::ActionAssigns
75
+
72
76
  include Ruact::ServerFunctions::ErrorRendering
73
77
  include Ruact::ValidationErrorsCollector
74
78
 
@@ -202,16 +206,16 @@ module Ruact
202
206
  # Story 9.2 AC2/AC4 (D1) — Bucket-2 success-path negotiation. When the
203
207
  # action finished without an explicit render on a function-call request
204
208
  # ({#__ruact_function_call?} — `Accept: application/json`, non-GET), serialize
205
- # the action's exposed instance variables (Rails `view_assigns`, verbatim —
206
- # the same set a view would see) as a JSON object keyed by ivar name, or
209
+ # the instance variables the ACTION assigned (Rails `view_assigns` minus
210
+ # what callbacks set and the action left alone — see
211
+ # {Ruact::ServerFunctions::ActionAssigns}) as a JSON object keyed by ivar name, or
207
212
  # `204 No Content` when none were set. Any other request shape falls through
208
213
  # to `super` so Bucket-1 rendering — the host's `Ruact::Controller` Flight
209
214
  # re-render, then Rails — is byte-for-byte unchanged (AC1).
210
215
  #
211
- # The exposed-ivar set is Rails' own `view_assigns` with no custom filtering:
212
- # Rails already excludes its protected `@_`-prefixed internals (including the
213
- # CSRF `@_marked_for_same_origin_verification` flag), so what remains is
214
- # exactly what the action assigned. Each value is serialized through the
216
+ # Rails' `view_assigns` already excludes its protected `@_`-prefixed
217
+ # internals (including the CSRF `@_marked_for_same_origin_verification`
218
+ # flag); ActionAssigns then drops what callbacks set. Each value is serialized through the
215
219
  # `ruact_props` / `Ruact::Serializable` / `strict_serialization` rules
216
220
  # ({Ruact::ServerFunctions::BucketTwoPayload}); a single ivar stays keyed
217
221
  # (no magic unwrap).
@@ -222,7 +226,7 @@ module Ruact
222
226
  # dev `@errors`) are dropped from the serialized assigns; Rails' own
223
227
  # `view_assigns` only filters a fixed set of `@_` ivars, not every `@__`
224
228
  # one (see {Ruact::ValidationErrorsCollector::RESERVED_ASSIGN_KEYS}).
225
- assigns = view_assigns.except(*ValidationErrorsCollector::RESERVED_ASSIGN_KEYS)
229
+ assigns = __ruact_action_assigns(view_assigns.except(*ValidationErrorsCollector::RESERVED_ASSIGN_KEYS))
226
230
 
227
231
  # Story 15.0 (F6) — reaching this branch means ruact itself is producing
228
232
  # the function-call response (fall-through: injection, plain JSON, or
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module ServerFunctions
5
+ # What a server function's JSON is made of (decision, 2026-10-03): the
6
+ # ivars the ACTION assigned, not everything a view would see.
7
+ #
8
+ # Two filters. Callbacks run before `send_action`, so what they put on the
9
+ # controller (a `set_post`, a `set_current_user`) is recorded there and
10
+ # left out of the JSON unless the action assigns it again. And some ivars
11
+ # are filled DURING the action without the action naming them — Devise's
12
+ # `current_user` memoizes `@current_user` on first call, Pundit's
13
+ # `authorize` sets `@pundit` — so names in
14
+ # `Ruact.config.server_function_hidden_ivars`, and every name starting
15
+ # with `_`, are never returned. Without these, a function call carried the
16
+ # signed-in user, and under `strict_serialization` (production's default)
17
+ # a user model without `ruact_props` made every call a 500. A page
18
+ # render's view still sees every ivar; only the function-call JSON
19
+ # narrows.
20
+ module ActionAssigns
21
+ # The snapshot's own name, kept out of what it filters.
22
+ CALLBACK_IVARS_KEY = "__ruact_callback_ivars"
23
+
24
+ private
25
+
26
+ def send_action(...)
27
+ if __ruact_function_call?
28
+ @__ruact_callback_ivars = instance_variables.to_h do |name|
29
+ [name.to_s.delete_prefix("@"), instance_variable_get(name)]
30
+ end
31
+ end
32
+ super
33
+ end
34
+
35
+ # +assigns+ minus what callbacks set and the action left holding the
36
+ # same object. Only a DIFFERENT object makes it the action's: setting
37
+ # the same value again (`@ok = false` after a callback did, a memoized
38
+ # helper returning what the callback already stored) leaves it out.
39
+ # Security first — a callback's `@current_user_id = 5` must not ride
40
+ # every response — at the price of a key that is absent when callback
41
+ # and action happen to agree.
42
+ def __ruact_action_assigns(assigns)
43
+ before = @__ruact_callback_ivars || {}
44
+ hidden = Ruact.config.server_function_hidden_ivars
45
+ assigns.reject do |name, value|
46
+ name == CALLBACK_IVARS_KEY || name.start_with?("_") || hidden.include?(name) ||
47
+ (before.key?(name) && before[name].equal?(value))
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -7,7 +7,7 @@ require_relative "../errors"
7
7
  module Ruact
8
8
  module ServerFunctions
9
9
  # Story 9.2 — pure serializer for the Bucket-2 (imperative `await fn()`)
10
- # response body. Takes the host action's exposed instance variables (Rails
10
+ # response body. Takes the instance variables the action returns (Rails
11
11
  # `view_assigns`, resolved by the caller) and produces a JSON-ready Ruby
12
12
  # Hash, keyed by ivar name, applying the SAME prop-exposure policy as the
13
13
  # Flight serializer ({Ruact::Flight::Serializer#serialize_unknown}):
@@ -10,7 +10,9 @@ module Ruact
10
10
  #
11
11
  # A component is TWO wire rows (Story 7.5 / FR108 semantics):
12
12
  #
13
- # * an `:import` row — `["<module path>", "<export name>", [chunks…]]` —
13
+ # * an `:import` row — `["<module path>", [chunks…], "<export name>"]`
14
+ # (React's order since Story 18-1; the older `[path, name, chunks]` is
15
+ # still read) —
14
16
  # sourced from the client manifest (`reference_for`); its `:id` is the
15
17
  # integer the model rows reference; AND
16
18
  # * one or more `:model` rows carrying a React element
@@ -27,8 +29,10 @@ module Ruact
27
29
  # A client-component element's type is "$L" followed by the import's hex id.
28
30
  CLIENT_REF = /\A\$L(?<hex>\h+)\z/
29
31
  # A string of 1024+ bytes travels as a `T` row, and the props hold a
30
- # `"$T<hex>"` reference to it (Story 17-0d).
31
- TEXT_REF = /\A\$T(?<hex>\h+)\z/
32
+ # `"$<hex>"` reference to it (Story 17-0d; `$T<hex>` before 18-1). A
33
+ # string that really starts with `$` travels escaped as `$$`, so this
34
+ # cannot match one.
35
+ TEXT_REF = /\A\$T?(?<hex>\h+)\z/
32
36
 
33
37
  # @param wire [String] the raw Flight wire byte string.
34
38
  def initialize(wire)
@@ -69,7 +73,10 @@ module Ruact
69
73
  next unless payload.is_a?(Array)
70
74
 
71
75
  module_path = payload[0]
72
- export_name = payload[1]
76
+ # The runtime's own components (`ruact:boundary`) are not the app's.
77
+ next if module_path.is_a?(String) && module_path.start_with?("ruact:")
78
+
79
+ export_name = payload[1].is_a?(Array) ? payload[2] : payload[1]
73
80
  acc[row[:id]] = {
74
81
  module_path: module_path,
75
82
  export_name: export_name,
@@ -24,10 +24,10 @@ module Ruact
24
24
  # `Thread.current`.
25
25
  #
26
26
  # @example
27
- # wire = "1:I[\"/L.jsx\",\"L\",[\"/L.jsx\"]]\n0:[\"$\",\"$L1\",null,{}]\n"
27
+ # wire = "1:I[\"/L.jsx\",[],\"L\"]\n0:[\"$\",\"$L1\",null,{}]\n"
28
28
  # Ruact::Testing::FlightWireParser.parse(wire)
29
29
  # # => [
30
- # # { id: 1, class: :import, payload: ["/L.jsx", "L", ["/L.jsx"]], raw: "1:I...\n" },
30
+ # # { id: 1, class: :import, payload: ["/L.jsx", [], "L"], raw: "1:I...\n" },
31
31
  # # { id: 0, class: :model, payload: ["$", "$L1", nil, {}], raw: "0:[\"$\"...\n" }
32
32
  # # ]
33
33
  class FlightWireParser
data/lib/ruact/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruact
4
- VERSION = "0.0.15"
4
+ VERSION = "0.0.17"
5
5
  end
data/lib/ruact.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "ruact/flight"
9
9
  require_relative "ruact/string_distance"
10
10
  require_relative "ruact/layout_source"
11
11
  require_relative "ruact/component_contract"
12
+ require_relative "ruact/component_attributes"
12
13
  require_relative "ruact/erb_preprocessor"
13
14
  require_relative "ruact/render_context"
14
15
  require_relative "ruact/html_converter"
@@ -21,6 +21,8 @@ import ruact, {
21
21
  BOOTSTRAP_VIRTUAL_ID,
22
22
  REGISTRY_VIRTUAL_ID,
23
23
  generateBootstrapSource,
24
+ resolveFlightClient,
25
+ flightClientSource,
24
26
  } from "./index.js";
25
27
 
26
28
  const RESOLVED = "\0" + BOOTSTRAP_VIRTUAL_ID;
@@ -53,10 +55,13 @@ describe("virtual:ruact/bootstrap — load (Story 14.2)", () => {
53
55
  });
54
56
 
55
57
  it("imports the runtime modules via ABSOLUTE fs specifiers, not relative", () => {
56
- expect(src).toMatch(/from '\/.*\/runtime\/flight-client\.js'/);
58
+ expect(src).toMatch(/from '\/.*\/runtime\/flight-modules\.js'/);
59
+ expect(src).toMatch(/import '\/.*\/runtime\/suspense-boundary\.js'/);
57
60
  expect(src).toMatch(/from '\/.*\/runtime\/ruact-router\.js'/);
61
+ // Story 18-1 — React's Flight client, through the plugin's virtual id.
62
+ expect(src).toContain("from 'virtual:ruact/flight-client'");
58
63
  // No `\0virtual:`-unresolvable relative import survives.
59
- expect(src).not.toMatch(/from\s+['"]\.\/flight-client\.js['"]/);
64
+ expect(src).not.toMatch(/(from|import)\s+['"]\.\/[\w.-]+\.js['"]/);
60
65
  expect(src).not.toMatch(/from\s+['"]\.\/ruact-router\.js['"]/);
61
66
  });
62
67
 
@@ -94,3 +99,40 @@ describe("generateBootstrapSource — rewrites imports only (Story 14.2)", () =>
94
99
  fs.rmSync(dir, { recursive: true, force: true });
95
100
  });
96
101
  });
102
+
103
+ // Story 18-1 — option C: which copy of React's Flight client answers.
104
+ describe("resolveFlightClient", () => {
105
+ const make = (pkg, installed) => {
106
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ruact-rfc-"));
107
+ if (pkg) fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(pkg));
108
+ if (installed) {
109
+ const mod = path.join(dir, "node_modules", "react-server-dom-webpack");
110
+ fs.mkdirSync(mod, { recursive: true });
111
+ fs.writeFileSync(path.join(mod, "package.json"), JSON.stringify({ name: "react-server-dom-webpack", version: installed }));
112
+ }
113
+ return dir;
114
+ };
115
+
116
+ it("uses the copy vendored in the gem when the app declares none", () => {
117
+ expect(resolveFlightClient(make({ dependencies: {} }), "development")).toMatchObject({ source: "vendored" });
118
+ });
119
+
120
+ it("uses the app's copy when the app declares and installs it", () => {
121
+ const dir = make({ dependencies: { "react-server-dom-webpack": "19.2.8" } }, "19.2.8");
122
+ expect(resolveFlightClient(dir, "production")).toEqual({ source: "app", version: "19.2.8", mode: "production" });
123
+ });
124
+
125
+ it("ignores a copy that is installed but not declared (another package's, hoisted)", () => {
126
+ expect(resolveFlightClient(make({ dependencies: {} }, "19.0.0"), "development")).toMatchObject({ source: "vendored" });
127
+ });
128
+
129
+ it("falls back to the vendored copy when declared but not installed yet", () => {
130
+ const dir = make({ devDependencies: { "react-server-dom-webpack": "19.2.8" } });
131
+ expect(resolveFlightClient(dir, "development")).toMatchObject({ source: "vendored" });
132
+ });
133
+
134
+ it("serves the app's copy behind the webpack globals, imported first", () => {
135
+ const src = flightClientSource({ source: "app", mode: "development" });
136
+ expect(src.indexOf("flight-webpack-globals.js")).toBeLessThan(src.indexOf("react-server-dom-webpack/client.browser"));
137
+ });
138
+ });
@@ -0,0 +1,286 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Story 18-1 — React's Flight client (the copy vendored in the gem) reads what
4
+ // the Ruby side writes. Every payload here is the serializer's own output,
5
+ // written and guarded against drift by spec/ruact/flight/*_fixtures_spec.rb.
6
+ //
7
+ // The suite runs once per client build: FLIGHT_CLIENT_MODE=development (the
8
+ // default) and =production (`npm test` runs both). CI also runs it against the
9
+ // lowest React the gem supports.
10
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { act, createElement as h, Suspense } from "react";
14
+ import { createRoot } from "react-dom/client";
15
+ import { createFromReadableStream } from "virtual:ruact/flight-client";
16
+ import { setModuleRegistry } from "./runtime/flight-modules.js";
17
+ import { setBoundaryErrorHandler } from "./runtime/suspense-boundary.js";
18
+ import { RootBoundary } from "./runtime/root-boundary.js";
19
+ import { labelledStream } from "./runtime/transport.js";
20
+
21
+ const MODE = process.env.FLIGHT_CLIENT_MODE || "development";
22
+ const FIXTURES = path.join(import.meta.dirname, "../../../spec/fixtures/flight");
23
+ const read = (name) => new Uint8Array(fs.readFileSync(path.join(FIXTURES, name)));
24
+ const readJSON = (name) => JSON.parse(fs.readFileSync(path.join(FIXTURES, name), "utf8"));
25
+
26
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
27
+
28
+ function Bad() {
29
+ throw new Error("boom");
30
+ }
31
+
32
+ function LikeButton({ likes, tags }) {
33
+ return h("button", null, `${likes}:${Array.isArray(tags) ? `[${tags.join(",")}]` : String(tags)}`);
34
+ }
35
+ setModuleRegistry({ "/LikeButton.jsx": { LikeButton }, "/Bad.jsx": { Bad } });
36
+
37
+ function streamOf(chunks) {
38
+ return new ReadableStream({
39
+ start(controller) {
40
+ for (const chunk of chunks) controller.enqueue(chunk);
41
+ controller.close();
42
+ },
43
+ });
44
+ }
45
+
46
+ // As the runtime reads a response: through ./runtime/transport.js. React's
47
+ // client returns a thenable, not a Promise; wrap it so `rejects` really checks.
48
+ const decode = (chunks) => Promise.resolve(createFromReadableStream(labelledStream(streamOf(chunks))));
49
+
50
+ async function mount(value) {
51
+ const container = document.createElement("div");
52
+ const root = createRoot(container);
53
+ await act(async () => {
54
+ root.render(h(Suspense, { fallback: "…" }, value));
55
+ });
56
+ return { container, root };
57
+ }
58
+
59
+ let consoleError;
60
+ beforeEach(() => {
61
+ consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
62
+ });
63
+ afterEach(() => {
64
+ consoleError.mockRestore();
65
+ setBoundaryErrorHandler(null);
66
+ });
67
+
68
+ describe(`React's Flight client (${MODE}) reads a page the Ruby side rendered`, () => {
69
+ // The development wire carries the slots React's development client reads.
70
+ const fixture = MODE === "development" ? "conformance_tree_dev.txt" : "conformance_tree.txt";
71
+ const html =
72
+ "<main><h1>Posts</h1>" +
73
+ "<article><button>1:[only]</button></article>" +
74
+ "<article><button>2:[only]</button></article>" +
75
+ "<article><button>3:[only]</button></article>" +
76
+ "<p>late</p><p>after</p></main>";
77
+
78
+ // FIRST render in this file on purpose: React reports a missing key once per
79
+ // location, so a later test would see nothing even on the wrong wire.
80
+ it("renders host elements, an ERB loop of client components and a Suspense child — and logs nothing", async () => {
81
+ const { container, root } = await mount(decode([read(fixture)]));
82
+ expect(container.innerHTML).toBe(html);
83
+ // The development build reports a missing key after the commit, on a later
84
+ // task; wait for it before concluding there is none.
85
+ await new Promise((resolve) => setTimeout(resolve, 50));
86
+ expect(consoleError).not.toHaveBeenCalled();
87
+ act(() => root.unmount());
88
+ });
89
+
90
+ it("keeps a one-element array prop a one-element array (Story 17.0a)", async () => {
91
+ const tree = await decode([read(fixture)]);
92
+ const article = tree.props.children[1];
93
+ expect(article.props.children.props.tags).toEqual(["only"]);
94
+ });
95
+
96
+ it("does not depend on where the network splits the bytes", async () => {
97
+ const bytes = read(fixture);
98
+ for (let cut = 1; cut < bytes.length; cut += 37) {
99
+ const { container, root } = await mount(decode([bytes.subarray(0, cut), bytes.subarray(cut)]));
100
+ expect(container.innerHTML).toBe(html);
101
+ act(() => root.unmount());
102
+ }
103
+ });
104
+ });
105
+
106
+ describe(`text rows (${MODE}) — Story 17-0d`, () => {
107
+ const expected = readJSON("text_framing_expected.json");
108
+
109
+ it("decodes every value the serializer framed, byte for byte", async () => {
110
+ expect(await decode([read("text_framing.txt")])).toEqual(expected);
111
+ });
112
+
113
+ it("decodes the same split at every byte, including inside lengths and code points", async () => {
114
+ const bytes = read("text_framing.txt");
115
+ for (let cut = 1; cut < bytes.length; cut += 1) {
116
+ expect(await decode([bytes.subarray(0, cut), bytes.subarray(cut)])).toEqual(expected);
117
+ }
118
+ });
119
+
120
+ it("decodes one byte at a time", async () => {
121
+ const bytes = read("text_framing.txt");
122
+ expect(await decode([...bytes].map((b) => Uint8Array.of(b)))).toEqual(expected);
123
+ });
124
+
125
+ it("puts long text inside Suspense content where the deferred row points", async () => {
126
+ const { container, root } = await mount(decode([read("text_framing_suspense.txt")]));
127
+ expect(container.querySelector("p").textContent).toBe(expected.multibyte);
128
+ act(() => root.unmount());
129
+ });
130
+
131
+ it("rejects a stream cut inside a text row instead of rendering part of it", async () => {
132
+ const bytes = read("text_framing.txt");
133
+ const cut = Buffer.from(bytes).indexOf(":T") + 10;
134
+ await expect(decode([bytes.subarray(0, cut)])).rejects.toThrow(/the response ended before the whole page arrived/);
135
+ });
136
+ });
137
+
138
+ describe(`dates and big integers (${MODE}) — Story 17-0c`, () => {
139
+ // Expected JSON tags what JSON cannot hold: { date: ISO } and { bigint: "…" }.
140
+ const revive = (value) => {
141
+ if (Array.isArray(value)) return value.map(revive);
142
+ if (value && typeof value === "object") {
143
+ if ("date" in value) return new Date(value.date);
144
+ if ("bigint" in value) return BigInt(value.bigint);
145
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, revive(v)]));
146
+ }
147
+ return value;
148
+ };
149
+
150
+ it("rebuilds every value with its JS type, nested ones included", async () => {
151
+ expect(await decode([read("scalar_round_trip.txt")])).toEqual(revive(readJSON("scalar_round_trip_expected.json")));
152
+ });
153
+
154
+ it("keeps a literal string that starts with $D a string", async () => {
155
+ expect((await decode([read("scalar_round_trip.txt")])).literal).toBe("$D2026-01-01 is a string");
156
+ });
157
+
158
+ it("reads NaN, ±Infinity, undefined and an escaped $", async () => {
159
+ const value = await decode([new TextEncoder().encode(
160
+ '0:{"nan":"$NaN","inf":"$Infinity","ninf":"$-Infinity","negz":"$-0","und":"$undefined","cash":"$$5"}\n',
161
+ )]);
162
+ expect(Number.isNaN(value.nan)).toBe(true);
163
+ expect(value.inf).toBe(Infinity);
164
+ expect(value.ninf).toBe(-Infinity);
165
+ expect(Object.is(value.negz, -0)).toBe(true);
166
+ expect(value.und).toBeUndefined();
167
+ expect(value.cash).toBe("$5");
168
+ });
169
+ });
170
+
171
+ describe(`a Suspense child whose row is an error (${MODE})`, () => {
172
+ const timeout = () => read("conformance_suspense_timeout.txt");
173
+ // The same page, its Suspense row now content: the timeout fixture with the
174
+ // error row replaced.
175
+ const recovered = () => new TextEncoder().encode(
176
+ new TextDecoder().decode(timeout()).replace(/^1:E.*$/m, '1:["$","p",null,{"children":"late"}]'),
177
+ );
178
+
179
+ it("keeps the fallback on screen and reports the server's message, in production too", async () => {
180
+ const reported = [];
181
+ setBoundaryErrorHandler((error) => reported.push(error));
182
+ const { container, root } = await mount(decode([timeout()]));
183
+
184
+ expect(container.innerHTML).toBe("<span>loading</span>");
185
+ expect(reported.map((e) => e.message)).toEqual(["[ruact] Server error: Suspense timeout exceeded"]);
186
+ act(() => root.unmount());
187
+ });
188
+
189
+ it("keeps the fallback when the response ends before the Suspense row, in production too", async () => {
190
+ const reported = [];
191
+ setBoundaryErrorHandler((error) => reported.push(error));
192
+ const cut = new TextDecoder().decode(timeout()).replace(/^1:E.*\n?/m, "");
193
+ const tree = await decode([new TextEncoder().encode(cut)]);
194
+ const container = document.createElement("div");
195
+ const root = createRoot(container);
196
+ await act(async () => {
197
+ root.render(h(RootBoundary, { tree }, h(Suspense, { fallback: "…" }, tree)));
198
+ });
199
+ expect(container.innerHTML).toBe("<span>loading</span>");
200
+ expect(reported.map((e) => e.message)).toEqual([
201
+ "[ruact] Server error: the response ended before the whole page arrived",
202
+ ]);
203
+ act(() => root.unmount());
204
+ });
205
+
206
+ it("keeps the fallback when the network fails mid-response", async () => {
207
+ const reported = [];
208
+ setBoundaryErrorHandler((error) => reported.push(error));
209
+ const head = new TextDecoder().decode(timeout()).replace(/^1:E.*\n?/m, "");
210
+ const failing = new ReadableStream({
211
+ start(controller) {
212
+ controller.enqueue(new TextEncoder().encode(head));
213
+ setTimeout(() => controller.error(new TypeError("network error")), 0);
214
+ },
215
+ });
216
+ const tree = await createFromReadableStream(labelledStream(failing));
217
+ const container = document.createElement("div");
218
+ const root = createRoot(container);
219
+ await act(async () => {
220
+ root.render(h(RootBoundary, { tree }, h(Suspense, { fallback: "…" }, tree)));
221
+ await new Promise((resolve) => setTimeout(resolve, 10));
222
+ });
223
+ expect(container.innerHTML).toBe("<span>loading</span>");
224
+ expect(reported).toHaveLength(1);
225
+ act(() => root.unmount());
226
+ });
227
+
228
+ it("does not stay failed: the next response for the same page renders", async () => {
229
+ setBoundaryErrorHandler(() => {});
230
+ const { container, root } = await mount(decode([timeout()]));
231
+ expect(container.innerHTML).toBe("<span>loading</span>");
232
+
233
+ // What revalidate() or a navigation to a page of the same shape does.
234
+ const next = decode([recovered()]);
235
+ await act(async () => {
236
+ root.render(h(Suspense, { fallback: "…" }, next));
237
+ });
238
+ expect(container.innerHTML).toBe("<p>late</p>");
239
+ act(() => root.unmount());
240
+ });
241
+
242
+ it("lets a crash in the app's own component through, to the root's boundary", async () => {
243
+ const reported = [];
244
+ setBoundaryErrorHandler((error) => reported.push(error));
245
+ const wire = new TextDecoder().decode(timeout())
246
+ .replace(/^1:E.*$/m, '1:["$","$L5",null,{}]')
247
+ .replace(/^/, '5:I["/Bad.jsx",[],"Bad"]\n');
248
+ const tree = await decode([new TextEncoder().encode(wire)]);
249
+ const container = document.createElement("div");
250
+ const root = createRoot(container);
251
+ await act(async () => {
252
+ root.render(h(RootBoundary, { tree }, tree));
253
+ });
254
+ expect(container.textContent).toBe("[ruact] Error: boom");
255
+ expect(reported).toEqual([]);
256
+ act(() => root.unmount());
257
+ });
258
+ });
259
+
260
+ describe(`a component missing from the registry (${MODE})`, () => {
261
+ it("shows an error in place of the page instead of removing it", async () => {
262
+ const tree = await decode([new TextEncoder().encode('1:I["/Gone.jsx",[],"Gone"]\n0:["$","$L1",null,{}]\n')]);
263
+ const container = document.createElement("div");
264
+ const root = createRoot(container);
265
+ await act(async () => {
266
+ root.render(h(RootBoundary, { tree }, tree));
267
+ });
268
+ expect(container.textContent).toBe(
269
+ '[ruact] Error: client component not registered: /Gone.jsx — is it under app/javascript/components/ with "use client" at the top?',
270
+ );
271
+ act(() => root.unmount());
272
+ });
273
+ });
274
+
275
+ describe(`responses the router reads (${MODE})`, () => {
276
+ it("decodes a redirect body to the object the router checks", async () => {
277
+ // redirect_row.txt is what Ruact::Controller#redirect_to writes (controller_spec guards it).
278
+ expect(await decode([read("redirect_row.txt")])).toEqual({ redirectUrl: "/posts/1", redirectType: "push" });
279
+ });
280
+
281
+ it("rejects a stream that ends before its root row", async () => {
282
+ await expect(decode([new TextEncoder().encode('1:["$","p",null,{}]\n')])).rejects.toThrow(
283
+ /the response ended before the whole page arrived/,
284
+ );
285
+ });
286
+ });