ruact 0.0.16 → 0.0.18
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 +47 -1
- data/README.md +1 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +6 -6
- data/lib/ruact/client_manifest.rb +29 -2
- data/lib/ruact/component_contract.rb +4 -2
- data/lib/ruact/controller.rb +3 -1
- data/lib/ruact/doctor.rb +64 -4
- data/lib/ruact/erb_preprocessor.rb +240 -137
- data/lib/ruact/errors.rb +15 -15
- data/lib/ruact/flight/renderer.rb +23 -9
- data/lib/ruact/flight/request.rb +31 -2
- data/lib/ruact/flight/row_emitter.rb +9 -0
- data/lib/ruact/flight/serializer.rb +22 -7
- data/lib/ruact/html_converter.rb +18 -0
- data/lib/ruact/render_pipeline.rb +16 -8
- data/lib/ruact/testing/component_query.rb +11 -4
- data/lib/ruact/testing/flight_wire_parser.rb +2 -2
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +13 -0
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +44 -2
- data/vendor/javascript/vite-plugin-ruact/flight-conformance.test.mjs +306 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +89 -14
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +804 -3
- data/vendor/javascript/vite-plugin-ruact/package.json +5 -1
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +53 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +46 -17
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-modules.js +40 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-webpack-globals.js +15 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/root-boundary.js +41 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +65 -83
- data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +83 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/transport.js +41 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/LICENSE +21 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/VERSION.json +5 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.development.js +5473 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.production.js +2126 -0
- data/vendor/javascript/vite-plugin-ruact/scripts/vendor-flight-client.mjs +124 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +14 -0
- metadata +13 -4
- data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +0 -499
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +0 -436
|
@@ -64,7 +64,9 @@ module Ruact
|
|
|
64
64
|
@request.increment_pending
|
|
65
65
|
row = RowEmitter.text(id, value)
|
|
66
66
|
@request.completed_regular_chunks << row
|
|
67
|
-
|
|
67
|
+
# A plain "$<hex>" reference: React reads "$T" as a temporary
|
|
68
|
+
# reference (Story 18-1).
|
|
69
|
+
return "$#{id.to_s(16)}"
|
|
68
70
|
end
|
|
69
71
|
|
|
70
72
|
# Escape leading $ so the client doesn't misinterpret it.
|
|
@@ -131,7 +133,16 @@ module Ruact
|
|
|
131
133
|
key = element.key
|
|
132
134
|
props = serialize_hash(element.props)
|
|
133
135
|
|
|
134
|
-
|
|
136
|
+
element_tuple(resolved_type, key, props)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# In development, React's client reads three more slots — owner, debug
|
|
140
|
+
# stack, and whether the children were validated. ERB has no `key`, so
|
|
141
|
+
# without the last one every element with sibling children or rendered in
|
|
142
|
+
# a loop logs React's "unique key" warning. Production reads four slots.
|
|
143
|
+
def element_tuple(type, key, props)
|
|
144
|
+
tuple = ["$", type, key, props]
|
|
145
|
+
@request.development ? tuple.push(nil, nil, 1) : tuple
|
|
135
146
|
end
|
|
136
147
|
|
|
137
148
|
# --- Suspense Boundary ---
|
|
@@ -143,11 +154,15 @@ module Ruact
|
|
|
143
154
|
|
|
144
155
|
fallback_value = element.fallback ? serialize_model(element.fallback) : nil
|
|
145
156
|
|
|
146
|
-
#
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
157
|
+
# React's shape (Story 18-1): the type is a reference to a
|
|
158
|
+
# `"$Sreact.suspense"` symbol row, and the deferred content is a lazy
|
|
159
|
+
# child the client resolves when its row arrives. The lazy child sits
|
|
160
|
+
# inside the runtime's error boundary: an error row for it (a Suspense
|
|
161
|
+
# timeout) keeps the fallback on screen and reaches the router's
|
|
162
|
+
# `onError`, where an unhandled render error would unmount the page.
|
|
163
|
+
boundary = element_tuple(@request.boundary_ref, nil,
|
|
164
|
+
{ "fallback" => fallback_value, "children" => "$L#{deferred_id.to_s(16)}" })
|
|
165
|
+
element_tuple(@request.suspense_symbol_ref, nil, { "fallback" => fallback_value, "children" => boundary })
|
|
151
166
|
end
|
|
152
167
|
|
|
153
168
|
# --- Unknown type fallback ---
|
data/lib/ruact/html_converter.rb
CHANGED
|
@@ -182,6 +182,7 @@ module Ruact
|
|
|
182
182
|
def convert_element(node)
|
|
183
183
|
tag = node.name.downcase
|
|
184
184
|
return convert_suspense_element(node) if tag == "ruact-suspense"
|
|
185
|
+
return convert_component_with_children(node) if tag == ErbPreprocessor::WRAPPER_TAG
|
|
185
186
|
|
|
186
187
|
props = build_props(node, tag)
|
|
187
188
|
children = convert_children(node)
|
|
@@ -190,6 +191,23 @@ module Ruact
|
|
|
190
191
|
Flight::ReactElement.new(type: tag, key: node["data-react-key"], props: props)
|
|
191
192
|
end
|
|
192
193
|
|
|
194
|
+
# Story 18-2 — a component with children: the wrapper the preprocessor put
|
|
195
|
+
# around the content between `<Card>` and `</Card>`. Its children become the
|
|
196
|
+
# component's `children` prop, converted like any element's; an empty pair
|
|
197
|
+
# passes none, like `<Card />`.
|
|
198
|
+
def convert_component_with_children(node)
|
|
199
|
+
token = node["data-ruact-token"]
|
|
200
|
+
entry = @registry.find { |c| c[:token] == token }
|
|
201
|
+
raise Ruact::HtmlConverterError, "ruact: no component registered for #{token.inspect}" unless entry
|
|
202
|
+
|
|
203
|
+
props = entry[:props]
|
|
204
|
+
children = convert_children(node)
|
|
205
|
+
unless children.empty?
|
|
206
|
+
props = props.merge("children" => children.length == 1 ? children.first : children)
|
|
207
|
+
end
|
|
208
|
+
Flight::ReactElement.new(type: entry[:ref], key: nil, props: props)
|
|
209
|
+
end
|
|
210
|
+
|
|
193
211
|
def convert_suspense_element(node)
|
|
194
212
|
fallback_text = node["data-ruact-fallback"] || ""
|
|
195
213
|
fallback = if fallback_text.empty?
|
|
@@ -15,10 +15,15 @@ module Ruact
|
|
|
15
15
|
class RenderPipeline
|
|
16
16
|
VALID_MODES = %i[string stream].freeze
|
|
17
17
|
|
|
18
|
-
|
|
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
|
|
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
|
|
224
|
+
streaming: streaming,
|
|
225
|
+
development: @development) { |row| y << row }
|
|
219
226
|
end
|
|
220
227
|
end
|
|
221
228
|
|
|
@@ -244,12 +251,13 @@ module Ruact
|
|
|
244
251
|
receiver.instance_variable_set(:@__ruact_render_context__, render_context)
|
|
245
252
|
return if receiver.respond_to?(:__ruact_component__)
|
|
246
253
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
254
|
+
ErbPreprocessor::PLACEHOLDERS.each do |helper, placeholder|
|
|
255
|
+
receiver.define_singleton_method(helper) do |name, props = {}|
|
|
256
|
+
ctx = instance_variable_get(:@__ruact_render_context__)
|
|
257
|
+
raise Ruact::Error, "ruact: #{helper} called outside an active render context" if ctx.nil?
|
|
250
258
|
|
|
251
|
-
|
|
252
|
-
|
|
259
|
+
placeholder.call(ctx.register(name, props))
|
|
260
|
+
end
|
|
253
261
|
end
|
|
254
262
|
end
|
|
255
263
|
end
|
|
@@ -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>"
|
|
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
|
-
# `"
|
|
31
|
-
|
|
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
|
-
|
|
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\"
|
|
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",
|
|
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
data/lib/ruact/view_helper.rb
CHANGED
|
@@ -32,6 +32,19 @@ module Ruact
|
|
|
32
32
|
"<!-- #{token} -->".html_safe
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# Story 18-2 — the opening of a component with children. Registers it like
|
|
36
|
+
# {#__ruact_component__} and opens the wrapper element whose content (the
|
|
37
|
+
# ERB between the tags, evaluated in place) HtmlConverter hands the
|
|
38
|
+
# component as `children`. The preprocessor writes the matching
|
|
39
|
+
# `</ruact-component>`.
|
|
40
|
+
def __ruact_component_open__(name, props = {})
|
|
41
|
+
ctx = @ruact_render_context
|
|
42
|
+
raise Ruact::Error, __ruact_outside_render_message(name) if ctx.nil?
|
|
43
|
+
|
|
44
|
+
token = ctx.register(name, props)
|
|
45
|
+
%(<ruact-component data-ruact-token="#{token}">).html_safe
|
|
46
|
+
end
|
|
47
|
+
|
|
35
48
|
# Story 17.0i — a client component in a template Rails is rendering on its
|
|
36
49
|
# own. Says which component and template, and the two ways to a ruact page:
|
|
37
50
|
# `ruact_render` for this template, or listing the action in the
|
|
@@ -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-
|
|
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+['"]\.\/
|
|
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,306 @@
|
|
|
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
|
+
function Card({ title, children }) {
|
|
36
|
+
return h("section", null, h("h2", null, title), children);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
setModuleRegistry({ "/LikeButton.jsx": { LikeButton }, "/Bad.jsx": { Bad }, "/Card.jsx": { Card } });
|
|
40
|
+
|
|
41
|
+
function streamOf(chunks) {
|
|
42
|
+
return new ReadableStream({
|
|
43
|
+
start(controller) {
|
|
44
|
+
for (const chunk of chunks) controller.enqueue(chunk);
|
|
45
|
+
controller.close();
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// As the runtime reads a response: through ./runtime/transport.js. React's
|
|
51
|
+
// client returns a thenable, not a Promise; wrap it so `rejects` really checks.
|
|
52
|
+
const decode = (chunks) => Promise.resolve(createFromReadableStream(labelledStream(streamOf(chunks))));
|
|
53
|
+
|
|
54
|
+
async function mount(value) {
|
|
55
|
+
const container = document.createElement("div");
|
|
56
|
+
const root = createRoot(container);
|
|
57
|
+
await act(async () => {
|
|
58
|
+
root.render(h(Suspense, { fallback: "…" }, value));
|
|
59
|
+
});
|
|
60
|
+
return { container, root };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let consoleError;
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
66
|
+
});
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
consoleError.mockRestore();
|
|
69
|
+
setBoundaryErrorHandler(null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe(`React's Flight client (${MODE}) reads a page the Ruby side rendered`, () => {
|
|
73
|
+
// The development wire carries the slots React's development client reads.
|
|
74
|
+
const fixture = MODE === "development" ? "conformance_tree_dev.txt" : "conformance_tree.txt";
|
|
75
|
+
const html =
|
|
76
|
+
"<main><h1>Posts</h1>" +
|
|
77
|
+
"<article><button>1:[only]</button></article>" +
|
|
78
|
+
"<article><button>2:[only]</button></article>" +
|
|
79
|
+
"<article><button>3:[only]</button></article>" +
|
|
80
|
+
"<p>late</p><p>after</p></main>";
|
|
81
|
+
|
|
82
|
+
// FIRST render in this file on purpose: React reports a missing key once per
|
|
83
|
+
// location, so a later test would see nothing even on the wrong wire.
|
|
84
|
+
it("renders host elements, an ERB loop of client components and a Suspense child — and logs nothing", async () => {
|
|
85
|
+
const { container, root } = await mount(decode([read(fixture)]));
|
|
86
|
+
expect(container.innerHTML).toBe(html);
|
|
87
|
+
// The development build reports a missing key after the commit, on a later
|
|
88
|
+
// task; wait for it before concluding there is none.
|
|
89
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
90
|
+
expect(consoleError).not.toHaveBeenCalled();
|
|
91
|
+
act(() => root.unmount());
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("keeps a one-element array prop a one-element array (Story 17.0a)", async () => {
|
|
95
|
+
const tree = await decode([read(fixture)]);
|
|
96
|
+
const article = tree.props.children[1];
|
|
97
|
+
expect(article.props.children.props.tags).toEqual(["only"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("does not depend on where the network splits the bytes", async () => {
|
|
101
|
+
const bytes = read(fixture);
|
|
102
|
+
for (let cut = 1; cut < bytes.length; cut += 37) {
|
|
103
|
+
const { container, root } = await mount(decode([bytes.subarray(0, cut), bytes.subarray(cut)]));
|
|
104
|
+
expect(container.innerHTML).toBe(html);
|
|
105
|
+
act(() => root.unmount());
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe(`a client component with children from ERB (${MODE}) — Story 18-2`, () => {
|
|
111
|
+
const fixture = MODE === "development" ? "conformance_children_dev.txt" : "conformance_children.txt";
|
|
112
|
+
|
|
113
|
+
it("renders the server's children inside the component, a nested one included, and logs nothing", async () => {
|
|
114
|
+
const { container, root } = await mount(decode([read(fixture)]));
|
|
115
|
+
expect(container.innerHTML).toBe(
|
|
116
|
+
"<section><h2>Server title</h2><p>from ERB</p>" +
|
|
117
|
+
"<button>1:[only]</button><button>2:[only]</button><button>3:[only]</button>" +
|
|
118
|
+
"<section><h2>inner</h2>nested</section></section>",
|
|
119
|
+
);
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
121
|
+
expect(consoleError).not.toHaveBeenCalled();
|
|
122
|
+
act(() => root.unmount());
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe(`text rows (${MODE}) — Story 17-0d`, () => {
|
|
127
|
+
const expected = readJSON("text_framing_expected.json");
|
|
128
|
+
|
|
129
|
+
it("decodes every value the serializer framed, byte for byte", async () => {
|
|
130
|
+
expect(await decode([read("text_framing.txt")])).toEqual(expected);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("decodes the same split at every byte, including inside lengths and code points", async () => {
|
|
134
|
+
const bytes = read("text_framing.txt");
|
|
135
|
+
for (let cut = 1; cut < bytes.length; cut += 1) {
|
|
136
|
+
expect(await decode([bytes.subarray(0, cut), bytes.subarray(cut)])).toEqual(expected);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("decodes one byte at a time", async () => {
|
|
141
|
+
const bytes = read("text_framing.txt");
|
|
142
|
+
expect(await decode([...bytes].map((b) => Uint8Array.of(b)))).toEqual(expected);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("puts long text inside Suspense content where the deferred row points", async () => {
|
|
146
|
+
const { container, root } = await mount(decode([read("text_framing_suspense.txt")]));
|
|
147
|
+
expect(container.querySelector("p").textContent).toBe(expected.multibyte);
|
|
148
|
+
act(() => root.unmount());
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("rejects a stream cut inside a text row instead of rendering part of it", async () => {
|
|
152
|
+
const bytes = read("text_framing.txt");
|
|
153
|
+
const cut = Buffer.from(bytes).indexOf(":T") + 10;
|
|
154
|
+
await expect(decode([bytes.subarray(0, cut)])).rejects.toThrow(/the response ended before the whole page arrived/);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe(`dates and big integers (${MODE}) — Story 17-0c`, () => {
|
|
159
|
+
// Expected JSON tags what JSON cannot hold: { date: ISO } and { bigint: "…" }.
|
|
160
|
+
const revive = (value) => {
|
|
161
|
+
if (Array.isArray(value)) return value.map(revive);
|
|
162
|
+
if (value && typeof value === "object") {
|
|
163
|
+
if ("date" in value) return new Date(value.date);
|
|
164
|
+
if ("bigint" in value) return BigInt(value.bigint);
|
|
165
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, revive(v)]));
|
|
166
|
+
}
|
|
167
|
+
return value;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
it("rebuilds every value with its JS type, nested ones included", async () => {
|
|
171
|
+
expect(await decode([read("scalar_round_trip.txt")])).toEqual(revive(readJSON("scalar_round_trip_expected.json")));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("keeps a literal string that starts with $D a string", async () => {
|
|
175
|
+
expect((await decode([read("scalar_round_trip.txt")])).literal).toBe("$D2026-01-01 is a string");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("reads NaN, ±Infinity, undefined and an escaped $", async () => {
|
|
179
|
+
const value = await decode([new TextEncoder().encode(
|
|
180
|
+
'0:{"nan":"$NaN","inf":"$Infinity","ninf":"$-Infinity","negz":"$-0","und":"$undefined","cash":"$$5"}\n',
|
|
181
|
+
)]);
|
|
182
|
+
expect(Number.isNaN(value.nan)).toBe(true);
|
|
183
|
+
expect(value.inf).toBe(Infinity);
|
|
184
|
+
expect(value.ninf).toBe(-Infinity);
|
|
185
|
+
expect(Object.is(value.negz, -0)).toBe(true);
|
|
186
|
+
expect(value.und).toBeUndefined();
|
|
187
|
+
expect(value.cash).toBe("$5");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe(`a Suspense child whose row is an error (${MODE})`, () => {
|
|
192
|
+
const timeout = () => read("conformance_suspense_timeout.txt");
|
|
193
|
+
// The same page, its Suspense row now content: the timeout fixture with the
|
|
194
|
+
// error row replaced.
|
|
195
|
+
const recovered = () => new TextEncoder().encode(
|
|
196
|
+
new TextDecoder().decode(timeout()).replace(/^1:E.*$/m, '1:["$","p",null,{"children":"late"}]'),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
it("keeps the fallback on screen and reports the server's message, in production too", async () => {
|
|
200
|
+
const reported = [];
|
|
201
|
+
setBoundaryErrorHandler((error) => reported.push(error));
|
|
202
|
+
const { container, root } = await mount(decode([timeout()]));
|
|
203
|
+
|
|
204
|
+
expect(container.innerHTML).toBe("<span>loading</span>");
|
|
205
|
+
expect(reported.map((e) => e.message)).toEqual(["[ruact] Server error: Suspense timeout exceeded"]);
|
|
206
|
+
act(() => root.unmount());
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("keeps the fallback when the response ends before the Suspense row, in production too", async () => {
|
|
210
|
+
const reported = [];
|
|
211
|
+
setBoundaryErrorHandler((error) => reported.push(error));
|
|
212
|
+
const cut = new TextDecoder().decode(timeout()).replace(/^1:E.*\n?/m, "");
|
|
213
|
+
const tree = await decode([new TextEncoder().encode(cut)]);
|
|
214
|
+
const container = document.createElement("div");
|
|
215
|
+
const root = createRoot(container);
|
|
216
|
+
await act(async () => {
|
|
217
|
+
root.render(h(RootBoundary, { tree }, h(Suspense, { fallback: "…" }, tree)));
|
|
218
|
+
});
|
|
219
|
+
expect(container.innerHTML).toBe("<span>loading</span>");
|
|
220
|
+
expect(reported.map((e) => e.message)).toEqual([
|
|
221
|
+
"[ruact] Server error: the response ended before the whole page arrived",
|
|
222
|
+
]);
|
|
223
|
+
act(() => root.unmount());
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("keeps the fallback when the network fails mid-response", async () => {
|
|
227
|
+
const reported = [];
|
|
228
|
+
setBoundaryErrorHandler((error) => reported.push(error));
|
|
229
|
+
const head = new TextDecoder().decode(timeout()).replace(/^1:E.*\n?/m, "");
|
|
230
|
+
const failing = new ReadableStream({
|
|
231
|
+
start(controller) {
|
|
232
|
+
controller.enqueue(new TextEncoder().encode(head));
|
|
233
|
+
setTimeout(() => controller.error(new TypeError("network error")), 0);
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
const tree = await createFromReadableStream(labelledStream(failing));
|
|
237
|
+
const container = document.createElement("div");
|
|
238
|
+
const root = createRoot(container);
|
|
239
|
+
await act(async () => {
|
|
240
|
+
root.render(h(RootBoundary, { tree }, h(Suspense, { fallback: "…" }, tree)));
|
|
241
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
242
|
+
});
|
|
243
|
+
expect(container.innerHTML).toBe("<span>loading</span>");
|
|
244
|
+
expect(reported).toHaveLength(1);
|
|
245
|
+
act(() => root.unmount());
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("does not stay failed: the next response for the same page renders", async () => {
|
|
249
|
+
setBoundaryErrorHandler(() => {});
|
|
250
|
+
const { container, root } = await mount(decode([timeout()]));
|
|
251
|
+
expect(container.innerHTML).toBe("<span>loading</span>");
|
|
252
|
+
|
|
253
|
+
// What revalidate() or a navigation to a page of the same shape does.
|
|
254
|
+
const next = decode([recovered()]);
|
|
255
|
+
await act(async () => {
|
|
256
|
+
root.render(h(Suspense, { fallback: "…" }, next));
|
|
257
|
+
});
|
|
258
|
+
expect(container.innerHTML).toBe("<p>late</p>");
|
|
259
|
+
act(() => root.unmount());
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("lets a crash in the app's own component through, to the root's boundary", async () => {
|
|
263
|
+
const reported = [];
|
|
264
|
+
setBoundaryErrorHandler((error) => reported.push(error));
|
|
265
|
+
const wire = new TextDecoder().decode(timeout())
|
|
266
|
+
.replace(/^1:E.*$/m, '1:["$","$L5",null,{}]')
|
|
267
|
+
.replace(/^/, '5:I["/Bad.jsx",[],"Bad"]\n');
|
|
268
|
+
const tree = await decode([new TextEncoder().encode(wire)]);
|
|
269
|
+
const container = document.createElement("div");
|
|
270
|
+
const root = createRoot(container);
|
|
271
|
+
await act(async () => {
|
|
272
|
+
root.render(h(RootBoundary, { tree }, tree));
|
|
273
|
+
});
|
|
274
|
+
expect(container.textContent).toBe("[ruact] Error: boom");
|
|
275
|
+
expect(reported).toEqual([]);
|
|
276
|
+
act(() => root.unmount());
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe(`a component missing from the registry (${MODE})`, () => {
|
|
281
|
+
it("shows an error in place of the page instead of removing it", async () => {
|
|
282
|
+
const tree = await decode([new TextEncoder().encode('1:I["/Gone.jsx",[],"Gone"]\n0:["$","$L1",null,{}]\n')]);
|
|
283
|
+
const container = document.createElement("div");
|
|
284
|
+
const root = createRoot(container);
|
|
285
|
+
await act(async () => {
|
|
286
|
+
root.render(h(RootBoundary, { tree }, tree));
|
|
287
|
+
});
|
|
288
|
+
expect(container.textContent).toBe(
|
|
289
|
+
'[ruact] Error: client component not registered: /Gone.jsx — is it under app/javascript/components/ with "use client" at the top?',
|
|
290
|
+
);
|
|
291
|
+
act(() => root.unmount());
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe(`responses the router reads (${MODE})`, () => {
|
|
296
|
+
it("decodes a redirect body to the object the router checks", async () => {
|
|
297
|
+
// redirect_row.txt is what Ruact::Controller#redirect_to writes (controller_spec guards it).
|
|
298
|
+
expect(await decode([read("redirect_row.txt")])).toEqual({ redirectUrl: "/posts/1", redirectType: "push" });
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("rejects a stream that ends before its root row", async () => {
|
|
302
|
+
await expect(decode([new TextEncoder().encode('1:["$","p",null,{}]\n')])).rejects.toThrow(
|
|
303
|
+
/the response ended before the whole page arrived/,
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
});
|