ruact 0.0.18 → 0.0.20
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 +41 -1
- data/CONTRIBUTING.md +10 -3
- data/README.md +1 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +13 -13
- data/lib/generators/ruact/install/templates/initializer.rb.tt +4 -1
- data/lib/ruact/client_manifest.rb +7 -7
- data/lib/ruact/configuration.rb +6 -1
- data/lib/ruact/controller/delivery.rb +247 -0
- data/lib/ruact/controller/document_rendering.rb +15 -12
- data/lib/ruact/controller/streaming.rb +241 -0
- data/lib/ruact/controller.rb +29 -48
- data/lib/ruact/deferred_render.rb +208 -0
- data/lib/ruact/doctor.rb +18 -1
- data/lib/ruact/erb_preprocessor.rb +203 -22
- data/lib/ruact/flight/react_element.rb +17 -2
- data/lib/ruact/flight/renderer.rb +91 -41
- data/lib/ruact/flight/request.rb +71 -27
- data/lib/ruact/flight/serializer.rb +3 -10
- data/lib/ruact/html_converter.rb +23 -31
- data/lib/ruact/inline_script.rb +24 -0
- data/lib/ruact/load_async_note.rb +51 -0
- data/lib/ruact/markers.rb +95 -0
- data/lib/ruact/navigation_boundary.rb +2 -2
- data/lib/ruact/railtie.rb +12 -0
- data/lib/ruact/render_context.rb +61 -6
- data/lib/ruact/render_pipeline.rb +29 -21
- data/lib/ruact/request_kind.rb +36 -0
- data/lib/ruact/server.rb +3 -7
- data/lib/ruact/testing/flight_extractor.rb +21 -4
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper/suspense.rb +62 -0
- data/lib/ruact/view_helper.rb +24 -32
- data/lib/ruact.rb +4 -0
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +16 -3
- data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +67 -0
- data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +107 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +23 -18
- data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +39 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +88 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +11 -17
- data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +1 -0
- metadata +14 -2
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module ViewHelper
|
|
5
|
+
# The helper a `<Suspense>` becomes ({ErbPreprocessor}). Part of
|
|
6
|
+
# {ViewHelper}.
|
|
7
|
+
module Suspense
|
|
8
|
+
# A `<Suspense>`: the preprocessor makes the ERB between its tags this
|
|
9
|
+
# block's body, and passes the `fallback` and `delay` attributes as the
|
|
10
|
+
# template wrote them.
|
|
11
|
+
#
|
|
12
|
+
# In an action that streams (`ruact_streaming`), the block is kept, not
|
|
13
|
+
# run: the marker ({Markers}) carries its index, and the response renders
|
|
14
|
+
# it after the shell is sent ({DeferredRender}). Anywhere else the content
|
|
15
|
+
# is evaluated here, in place, so the page renders as one piece — and, in
|
|
16
|
+
# development, the log says the action could stream.
|
|
17
|
+
#
|
|
18
|
+
# Only what the block writes is the content: a value it ends on is not
|
|
19
|
+
# (`capture` would print a block's value when it writes nothing).
|
|
20
|
+
def __ruact_suspense__(fallback, delay = nil, &block)
|
|
21
|
+
ctx = @ruact_render_context
|
|
22
|
+
if ctx&.deferring?
|
|
23
|
+
index = ctx.defer_block(RenderContext::Block.new(self, block, @virtual_path, @current_template,
|
|
24
|
+
caller_locations(1, 1).first))
|
|
25
|
+
return "#{Markers.suspense_open(fallback, delay, block: index)}#{Markers::SUSPENSE_CLOSE}".html_safe
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
__ruact_note_unstreamed_suspense(ctx, caller_locations(1, 1).first) if ctx
|
|
29
|
+
content = capture do
|
|
30
|
+
yield
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
"#{Markers.suspense_open(fallback, delay)}#{content}#{Markers::SUSPENSE_CLOSE}".html_safe
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Story 19-1 — a `<Suspense>` in an action that does not stream renders its
|
|
37
|
+
# content before the page is sent, as it always did. Development says so,
|
|
38
|
+
# once per response, with the line that makes the action stream.
|
|
39
|
+
def __ruact_note_unstreamed_suspense(ctx, location)
|
|
40
|
+
return unless defined?(Rails) && Rails.env.development?
|
|
41
|
+
return if ctx.unstreamed_suspense_noted?
|
|
42
|
+
|
|
43
|
+
ctx.note_unstreamed_suspense!
|
|
44
|
+
|
|
45
|
+
owner = respond_to?(:controller) ? controller : nil
|
|
46
|
+
return unless owner.respond_to?(:action_name) && owner.respond_to?(:logger)
|
|
47
|
+
# Declared, and answering a request it cannot stream to (HTTP/1.0).
|
|
48
|
+
return if owner.class.respond_to?(:ruact_streaming?) && owner.class.ruact_streaming?(owner.action_name)
|
|
49
|
+
|
|
50
|
+
where = location ? " (#{location.path.delete_prefix("#{Rails.root}/")}:#{location.lineno})" : ""
|
|
51
|
+
owner.logger&.warn(
|
|
52
|
+
"[ruact] #{owner.class.name}##{owner.action_name} renders a <Suspense>#{where} in an action " \
|
|
53
|
+
"that does not stream, so its content ran before the page was sent. To send the page first " \
|
|
54
|
+
"and the content when it is ready, add `ruact_streaming only: :#{owner.action_name}` to " \
|
|
55
|
+
"#{owner.class.name}."
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private :__ruact_note_unstreamed_suspense
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/ruact/view_helper.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "erb"
|
|
4
4
|
require "json"
|
|
5
5
|
require "socket"
|
|
6
|
+
require_relative "view_helper/suspense"
|
|
6
7
|
|
|
7
8
|
module Ruact
|
|
8
9
|
# ActionView helper module included in ActionView::Base via Railtie.
|
|
@@ -14,6 +15,9 @@ module Ruact
|
|
|
14
15
|
# +@ruact_render_context+ and copied to the view by Rails's view_assigns
|
|
15
16
|
# plumbing — see Story 7.9 / Bug 7.8-B) is per-request — no shared state.
|
|
16
17
|
module ViewHelper
|
|
18
|
+
# `<Suspense>`: the content in place, or kept for after the shell.
|
|
19
|
+
include Suspense
|
|
20
|
+
|
|
17
21
|
# Registers +name+ with +props+ in the per-render RenderContext (set by
|
|
18
22
|
# Ruact::Controller#ruact_render on the controller as +@ruact_render_context+;
|
|
19
23
|
# Rails copies it to the view via +_assigns_for_view_context+ because the
|
|
@@ -28,8 +32,7 @@ module Ruact
|
|
|
28
32
|
ctx = @ruact_render_context
|
|
29
33
|
raise Ruact::Error, __ruact_outside_render_message(name) if ctx.nil?
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
"<!-- #{token} -->".html_safe
|
|
35
|
+
Markers.component(ctx.register(name, props)).html_safe
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
# Story 18-2 — the opening of a component with children. Registers it like
|
|
@@ -41,8 +44,7 @@ module Ruact
|
|
|
41
44
|
ctx = @ruact_render_context
|
|
42
45
|
raise Ruact::Error, __ruact_outside_render_message(name) if ctx.nil?
|
|
43
46
|
|
|
44
|
-
|
|
45
|
-
%(<ruact-component data-ruact-token="#{token}">).html_safe
|
|
47
|
+
Markers.component_open(ctx.register(name, props)).html_safe
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
# Story 17.0i — a client component in a template Rails is rendering on its
|
|
@@ -228,31 +230,15 @@ module Ruact
|
|
|
228
230
|
|
|
229
231
|
# The `__FLIGHT_DATA` inline bootstrap `<script>` — pushes the per-render
|
|
230
232
|
# Flight payload onto the global queue the bootstrap entry drains on boot.
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
# escaped as `\uXXXX`. No byte of the payload can then close the `<script>`
|
|
235
|
-
# — `</SCRIPT>`, `</script >` and `<!--` included — and every character
|
|
236
|
-
# reaches JavaScript intact (a Ruby `String#inspect` literal did neither:
|
|
237
|
-
# it escaped only a lowercase `</script>`, and its `\e`-style escapes mean
|
|
238
|
-
# something else in JS).
|
|
239
|
-
SCRIPT_UNSAFE = /[<>&\u2028\u2029]/
|
|
240
|
-
private_constant :SCRIPT_UNSAFE
|
|
241
|
-
|
|
242
|
-
# A JSON string literal that is also safe inside a `<script>` element.
|
|
243
|
-
# The payload is UTF-8 by construction (the serializer normalizes every
|
|
244
|
-
# string); a binary-tagged buffer is retagged, not transcoded.
|
|
245
|
-
def __ruact_script_safe_json(string)
|
|
246
|
-
utf8 = string.encoding == Encoding::UTF_8 ? string : string.dup.force_encoding(Encoding::UTF_8)
|
|
247
|
-
JSON.generate(utf8).gsub(SCRIPT_UNSAFE) { |char| format("\\u%04x", char.ord) }
|
|
248
|
-
end
|
|
249
|
-
|
|
233
|
+
# The payload is written as {InlineScript.string_literal} writes it (a
|
|
234
|
+
# Ruby `String#inspect` literal, used before, escaped only a lowercase
|
|
235
|
+
# `</script>`, and its `\e`-style escapes mean something else in JS).
|
|
250
236
|
def ruact_flight_data_script(flight_payload)
|
|
251
237
|
<<~HTML.strip
|
|
252
238
|
<script>
|
|
253
239
|
(function() {
|
|
254
240
|
var d = (self.__FLIGHT_DATA = self.__FLIGHT_DATA || []);
|
|
255
|
-
d.push(#{
|
|
241
|
+
d.push(#{InlineScript.string_literal(flight_payload)});
|
|
256
242
|
})();
|
|
257
243
|
</script>
|
|
258
244
|
HTML
|
|
@@ -262,32 +248,38 @@ module Ruact
|
|
|
262
248
|
# the Vite dev server (with the react-refresh preamble + `@vite/client`);
|
|
263
249
|
# prod reads the hashed URL from the Vite manifest. Both target the SAME
|
|
264
250
|
# virtual entry id (`Ruact.bootstrap_virtual_id`) so dev/prod stay in lockstep.
|
|
251
|
+
#
|
|
252
|
+
# Story 19-1 — the entry is `async`: it runs as soon as it has loaded. A
|
|
253
|
+
# module script is otherwise deferred to the end of the document, and a
|
|
254
|
+
# page that streams ends after its last `<Suspense>` — its fallback would
|
|
255
|
+
# never show. The bootstrap then reads the rows the document pushes while
|
|
256
|
+
# it is still open.
|
|
265
257
|
def ruact_vite_tags
|
|
266
258
|
if Rails.env.development? && vite_dev_running?
|
|
267
259
|
# @vitejs/plugin-react normally injects this preamble by processing index.html.
|
|
268
260
|
# Since our HTML is generated by Rails (not Vite), we inject it manually.
|
|
269
261
|
# Without it, every JSX file throws "can't detect preamble" at runtime.
|
|
262
|
+
# One async module, so the preamble still runs before any component
|
|
263
|
+
# module: it installs the preamble, then imports the client and the
|
|
264
|
+
# bootstrap, in that order.
|
|
270
265
|
dev_server = Ruact.config.vite_dev_server.chomp("/")
|
|
271
|
-
|
|
272
|
-
<script type="module">
|
|
266
|
+
<<~HTML
|
|
267
|
+
<script type="module" async>
|
|
273
268
|
import RefreshRuntime from '#{dev_server}/@react-refresh';
|
|
274
269
|
RefreshRuntime.injectIntoGlobalHook(window);
|
|
275
270
|
window.$RefreshReg$ = () => {};
|
|
276
271
|
window.$RefreshSig$ = () => (type) => type;
|
|
277
272
|
window.__vite_plugin_react_preamble_installed__ = true;
|
|
273
|
+
await import('#{dev_server}/@vite/client');
|
|
274
|
+
await import('#{ruact_bootstrap_dev_url}');
|
|
278
275
|
</script>
|
|
279
|
-
JS
|
|
280
|
-
|
|
281
|
-
react_preamble + <<~HTML
|
|
282
|
-
<script type="module" src="#{dev_server}/@vite/client"></script>
|
|
283
|
-
<script type="module" src="#{ruact_bootstrap_dev_url}"></script>
|
|
284
276
|
HTML
|
|
285
277
|
else
|
|
286
278
|
# Production: read the hashed URL from the Vite manifest, keyed on the
|
|
287
279
|
# virtual entry id (the manifest key Vite emits for the virtual input).
|
|
288
280
|
entry = vite_manifest_entry(Ruact.bootstrap_virtual_id)
|
|
289
281
|
src = entry ? "/assets/#{entry['file']}" : "/assets/application.js"
|
|
290
|
-
tag = %(<script type="module" src="#{src}"></script>)
|
|
282
|
+
tag = %(<script type="module" async src="#{src}"></script>)
|
|
291
283
|
# Story 17-0k — in development with neither the Vite dev server nor a
|
|
292
284
|
# build, that script 404s and the page stays empty with no error.
|
|
293
285
|
Rails.env.development? && entry.nil? ? "#{ruact_vite_down_warning}\n#{tag}" : tag
|
data/lib/ruact.rb
CHANGED
|
@@ -10,12 +10,16 @@ require_relative "ruact/string_distance"
|
|
|
10
10
|
require_relative "ruact/layout_source"
|
|
11
11
|
require_relative "ruact/component_contract"
|
|
12
12
|
require_relative "ruact/component_attributes"
|
|
13
|
+
require_relative "ruact/markers"
|
|
14
|
+
require_relative "ruact/request_kind"
|
|
15
|
+
require_relative "ruact/inline_script"
|
|
13
16
|
require_relative "ruact/erb_preprocessor"
|
|
14
17
|
require_relative "ruact/render_context"
|
|
15
18
|
require_relative "ruact/html_converter"
|
|
16
19
|
require_relative "ruact/client_manifest"
|
|
17
20
|
require_relative "ruact/manifest_resolver"
|
|
18
21
|
require_relative "ruact/render_pipeline"
|
|
22
|
+
require_relative "ruact/deferred_render"
|
|
19
23
|
require_relative "ruact/view_helper"
|
|
20
24
|
require_relative "ruact/erb_preprocessor_hook"
|
|
21
25
|
require_relative "ruact/server_functions"
|
|
@@ -56,15 +56,28 @@ describe("virtual:ruact/bootstrap — load (Story 14.2)", () => {
|
|
|
56
56
|
|
|
57
57
|
it("imports the runtime modules via ABSOLUTE fs specifiers, not relative", () => {
|
|
58
58
|
expect(src).toMatch(/from '\/.*\/runtime\/flight-modules\.js'/);
|
|
59
|
-
expect(src).toMatch(/
|
|
59
|
+
expect(src).toMatch(/from '\/.*\/runtime\/suspense-boundary\.js'/);
|
|
60
60
|
expect(src).toMatch(/from '\/.*\/runtime\/ruact-router\.js'/);
|
|
61
|
-
//
|
|
62
|
-
|
|
61
|
+
// The page is opened through page-stream.js, which reads React's Flight
|
|
62
|
+
// client through the plugin's virtual id (Story 18-1).
|
|
63
|
+
expect(src).toMatch(/from '\/.*\/runtime\/page-stream\.js'/);
|
|
63
64
|
// No `\0virtual:`-unresolvable relative import survives.
|
|
64
65
|
expect(src).not.toMatch(/(from|import)\s+['"]\.\/[\w.-]+\.js['"]/);
|
|
65
66
|
expect(src).not.toMatch(/from\s+['"]\.\/ruact-router\.js['"]/);
|
|
66
67
|
});
|
|
67
68
|
|
|
69
|
+
// Story 19-1 — the boundary handler is in place before the first render: a
|
|
70
|
+
// row that is already an error when the page mounts is caught during that
|
|
71
|
+
// first commit, before the router's effect runs.
|
|
72
|
+
it("installs the boundary error handler before it mounts, and mounts into the root once it exists", () => {
|
|
73
|
+
expect(src).toMatch(/from '\/.*\/runtime\/dev-overlay\.js'/);
|
|
74
|
+
expect(src.indexOf("setBoundaryErrorHandler((error)")).toBeGreaterThan(-1);
|
|
75
|
+
expect(src.indexOf("setBoundaryErrorHandler((error)")).toBeLessThan(src.indexOf("createRoot(container"));
|
|
76
|
+
expect(src).toContain("Promise.all([initialTree, whenRoot()])");
|
|
77
|
+
expect(src).toContain("openPage(documentRows(flightData))");
|
|
78
|
+
expect(src).toContain("setupRouter({ onNavigate: setCurrent, onBoundaryError })");
|
|
79
|
+
});
|
|
80
|
+
|
|
68
81
|
it("contains no JSX syntax (loads via the default loader like the registry)", () => {
|
|
69
82
|
expect(src).not.toContain("<App");
|
|
70
83
|
expect(src).toContain("createElement(App)");
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// Story 19-1 — the entry is `async`: it can run before the parser reaches the
|
|
4
|
+
// page's `<div id="root">`, and a <Suspense> that failed on the server is shown
|
|
5
|
+
// over the page in development.
|
|
6
|
+
import { describe, it, expect } from "vitest";
|
|
7
|
+
import { whenRoot } from "./runtime/page-stream.js";
|
|
8
|
+
import { showDevOverlay } from "./runtime/dev-overlay.js";
|
|
9
|
+
|
|
10
|
+
function loadingDocument() {
|
|
11
|
+
const doc = document.implementation.createHTMLDocument("page");
|
|
12
|
+
Object.defineProperty(doc, "readyState", { value: "loading", configurable: true });
|
|
13
|
+
return doc;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("whenRoot", () => {
|
|
17
|
+
it("gives the root at once when it is there", async () => {
|
|
18
|
+
const doc = document.implementation.createHTMLDocument("page");
|
|
19
|
+
doc.body.innerHTML = '<div id="root"></div>';
|
|
20
|
+
await expect(whenRoot(doc)).resolves.toBe(doc.getElementById("root"));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("waits for a root the parser has not reached yet", async () => {
|
|
24
|
+
const doc = loadingDocument();
|
|
25
|
+
let found = null;
|
|
26
|
+
const waiting = whenRoot(doc).then((root) => { found = root; });
|
|
27
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
28
|
+
expect(found).toBeNull();
|
|
29
|
+
|
|
30
|
+
const root = doc.createElement("div");
|
|
31
|
+
root.id = "root";
|
|
32
|
+
doc.body.appendChild(root);
|
|
33
|
+
await waiting;
|
|
34
|
+
expect(found).toBe(root);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("gives up with null when the document ends without one", async () => {
|
|
38
|
+
const doc = loadingDocument();
|
|
39
|
+
const waiting = whenRoot(doc);
|
|
40
|
+
doc.dispatchEvent(new Event("DOMContentLoaded"));
|
|
41
|
+
await expect(waiting).resolves.toBeNull();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("showDevOverlay", () => {
|
|
46
|
+
it("shows the server's error over the page, and closes on a click", () => {
|
|
47
|
+
const cause = new Error("ArgumentError: boom in a block");
|
|
48
|
+
cause.stack = "ArgumentError: boom in a block\n at app/views/posts/show.html.erb:4";
|
|
49
|
+
showDevOverlay(new Error("[ruact] Server error: ArgumentError: boom in a block", { cause }));
|
|
50
|
+
|
|
51
|
+
const overlay = document.getElementById("ruact-dev-overlay");
|
|
52
|
+
expect(overlay.querySelector("[data-overlay-message]").textContent).toBe("ArgumentError: boom in a block");
|
|
53
|
+
expect(overlay.querySelector("[data-overlay-stack]").textContent).toContain("show.html.erb:4");
|
|
54
|
+
|
|
55
|
+
overlay.click();
|
|
56
|
+
expect(document.getElementById("ruact-dev-overlay")).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("shows the latest failure in the one overlay", () => {
|
|
60
|
+
showDevOverlay(new Error("first"));
|
|
61
|
+
showDevOverlay(new Error("second"));
|
|
62
|
+
const overlays = document.querySelectorAll("#ruact-dev-overlay");
|
|
63
|
+
expect(overlays).toHaveLength(1);
|
|
64
|
+
expect(overlays[0].querySelector("[data-overlay-message]").textContent).toBe("second");
|
|
65
|
+
overlays[0].click();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The two ways a page is opened read the same way: the rows a document inlined
|
|
2
|
+
// in `__FLIGHT_DATA`, and a fetch body that may end early.
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
import { documentRows, openPage } from "./runtime/page-stream.js";
|
|
5
|
+
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
|
|
8
|
+
function bodyOf(chunks, { error } = {}) {
|
|
9
|
+
return new ReadableStream({
|
|
10
|
+
start(controller) {
|
|
11
|
+
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
|
|
12
|
+
if (error) controller.error(error);
|
|
13
|
+
else controller.close();
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("openPage", () => {
|
|
19
|
+
it("opens the rows a document inlined, joined in the order they were pushed", async () => {
|
|
20
|
+
const page = openPage(documentRows(['1:"wor', 'ld"\n0:["hello","$1"]\n'], { readyState: "complete" }));
|
|
21
|
+
expect(await page.root).toEqual(["hello", "world"]);
|
|
22
|
+
await expect(page.end).resolves.toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("resolves the root at row 0, before a deferred row has arrived", async () => {
|
|
26
|
+
let push;
|
|
27
|
+
const body = new ReadableStream({
|
|
28
|
+
start(controller) {
|
|
29
|
+
controller.enqueue(encoder.encode('0:{"later":"$@1"}\n'));
|
|
30
|
+
push = (row) => {
|
|
31
|
+
controller.enqueue(encoder.encode(row));
|
|
32
|
+
controller.close();
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
const page = openPage(body);
|
|
37
|
+
const root = await page.root;
|
|
38
|
+
expect(root.later).toBeInstanceOf(Promise);
|
|
39
|
+
|
|
40
|
+
push('1:"arrived"\n');
|
|
41
|
+
await expect(root.later).resolves.toBe("arrived");
|
|
42
|
+
await expect(page.end).resolves.toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("labels a body that ends before the root as the transport's failure, and rejects the end", async () => {
|
|
46
|
+
const page = openPage(bodyOf([], { error: new TypeError("network down") }));
|
|
47
|
+
await expect(page.root).rejects.toMatchObject({ digest: "ruact:connection-closed" });
|
|
48
|
+
await expect(page.end).rejects.toThrow("network down");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Story 19-1 — a page that streams: the document is still open when the
|
|
53
|
+
// bootstrap runs, and pushes each <Suspense>'s rows as they are ready.
|
|
54
|
+
describe("documentRows", () => {
|
|
55
|
+
function openDocument() {
|
|
56
|
+
const listeners = {};
|
|
57
|
+
return {
|
|
58
|
+
readyState: "loading",
|
|
59
|
+
addEventListener(type, listener) { listeners[type] = listener; },
|
|
60
|
+
finish() {
|
|
61
|
+
this.readyState = "interactive";
|
|
62
|
+
listeners.DOMContentLoaded?.();
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
it("reads what the queue holds, then each row pushed later, until the document ends", async () => {
|
|
68
|
+
const doc = openDocument();
|
|
69
|
+
const queue = ['0:{"later":"$@1"}\n'];
|
|
70
|
+
const page = openPage(documentRows(queue, doc));
|
|
71
|
+
const root = await page.root;
|
|
72
|
+
expect(root.later).toBeInstanceOf(Promise);
|
|
73
|
+
|
|
74
|
+
queue.push('1:"arrived"\n');
|
|
75
|
+
await expect(root.later).resolves.toBe("arrived");
|
|
76
|
+
expect(queue).toHaveLength(0);
|
|
77
|
+
|
|
78
|
+
doc.finish();
|
|
79
|
+
await expect(page.end).resolves.toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("keeps the stream open while the document loads, and closes it when the document ends", async () => {
|
|
83
|
+
const doc = openDocument();
|
|
84
|
+
const stream = documentRows(['0:"shell"\n'], doc);
|
|
85
|
+
const reader = stream.getReader();
|
|
86
|
+
await reader.read();
|
|
87
|
+
let closed = false;
|
|
88
|
+
const next = reader.read().then((result) => { closed = result.done; });
|
|
89
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
90
|
+
expect(closed).toBe(false);
|
|
91
|
+
doc.finish();
|
|
92
|
+
await next;
|
|
93
|
+
expect(closed).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("closes at once when the document has already ended", async () => {
|
|
97
|
+
const reader = documentRows(['0:"x"\n'], { readyState: "complete" }).getReader();
|
|
98
|
+
await reader.read();
|
|
99
|
+
await expect(reader.read()).resolves.toMatchObject({ done: true });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("ignores a row pushed after the document ended instead of throwing", () => {
|
|
103
|
+
const queue = [];
|
|
104
|
+
documentRows(queue, { readyState: "complete" });
|
|
105
|
+
expect(() => queue.push('9:"late"\n')).not.toThrow();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -18,15 +18,16 @@
|
|
|
18
18
|
// transforming a `\0virtual:` id.
|
|
19
19
|
import { createRoot } from 'react-dom/client';
|
|
20
20
|
import { createElement, useState, useEffect } from 'react';
|
|
21
|
-
// Story 18-1 — React's own Flight client: the copy vendored in the gem, or the
|
|
22
|
-
// app's `react-server-dom-webpack` when installed (the plugin decides).
|
|
23
|
-
import { createFromReadableStream } from 'virtual:ruact/flight-client';
|
|
24
21
|
import { setModuleRegistry } from './flight-modules.js';
|
|
25
22
|
// Registers the boundary the server wraps each Suspense child in.
|
|
26
|
-
import './suspense-boundary.js';
|
|
23
|
+
import { setBoundaryErrorHandler } from './suspense-boundary.js';
|
|
27
24
|
import { RootBoundary, onCaughtError } from './root-boundary.js';
|
|
28
|
-
|
|
25
|
+
// The page, opened from its Flight bytes with React's own Flight client (the
|
|
26
|
+
// copy vendored in the gem, or the app's `react-server-dom-webpack` when it
|
|
27
|
+
// declares one: the plugin decides).
|
|
28
|
+
import { documentRows, openPage, whenRoot } from './page-stream.js';
|
|
29
29
|
import { setupRouter, teardownRouter } from './ruact-router.js';
|
|
30
|
+
import { showDevOverlay } from './dev-overlay.js';
|
|
30
31
|
|
|
31
32
|
// MODULE_REGISTRY maps react-client-manifest "id" values to component exports.
|
|
32
33
|
// It is auto-derived by vite-plugin-ruact from the SAME scan that emits
|
|
@@ -56,31 +57,35 @@ if (!flightData || flightData.length === 0) {
|
|
|
56
57
|
root.textContent = '[ruact] No Flight data found — is the Rails server running?';
|
|
57
58
|
}
|
|
58
59
|
} else {
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
60
|
+
// Story 19-1 — in development a <Suspense> that failed on the server is shown
|
|
61
|
+
// over the page. The handler is in place before anything renders: a row that
|
|
62
|
+
// is already an error when the page mounts is caught during that first
|
|
63
|
+
// commit, before the router's effect would install one.
|
|
64
|
+
const onBoundaryError = import.meta.env?.DEV ? (error) => showDevOverlay(error) : null;
|
|
65
|
+
setBoundaryErrorHandler((error) => {
|
|
66
|
+
console.error(error.message);
|
|
67
|
+
onBoundaryError?.(error);
|
|
67
68
|
});
|
|
68
69
|
|
|
70
|
+
// The document's rows, as the stream the client reads — open while the
|
|
71
|
+
// document is: a page that streams pushes each <Suspense>'s rows as they are
|
|
72
|
+
// ready. The root is mounted once the tree resolves, so the first paint is
|
|
73
|
+
// the page with its fallbacks.
|
|
69
74
|
let initialTree;
|
|
70
75
|
try {
|
|
71
|
-
initialTree =
|
|
76
|
+
initialTree = openPage(documentRows(flightData)).root;
|
|
72
77
|
} catch (err) {
|
|
73
78
|
showBootError(err);
|
|
74
79
|
throw err;
|
|
75
80
|
}
|
|
76
81
|
|
|
77
|
-
initialTree.then(
|
|
78
|
-
(tree) => {
|
|
82
|
+
Promise.all([initialTree, whenRoot()]).then(
|
|
83
|
+
([tree, container]) => {
|
|
79
84
|
function App() {
|
|
80
85
|
const [current, setCurrent] = useState(() => tree);
|
|
81
86
|
|
|
82
87
|
useEffect(() => {
|
|
83
|
-
setupRouter({ onNavigate: setCurrent });
|
|
88
|
+
setupRouter({ onNavigate: setCurrent, onBoundaryError });
|
|
84
89
|
return () => teardownRouter();
|
|
85
90
|
}, []);
|
|
86
91
|
|
|
@@ -88,7 +93,7 @@ if (!flightData || flightData.length === 0) {
|
|
|
88
93
|
return createElement(RootBoundary, { tree: current }, current);
|
|
89
94
|
}
|
|
90
95
|
|
|
91
|
-
createRoot(
|
|
96
|
+
createRoot(container, { onCaughtError }).render(createElement(App));
|
|
92
97
|
},
|
|
93
98
|
(err) => {
|
|
94
99
|
showBootError(err);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Story 19-1 (ADR D5) — in development, a `<Suspense>` whose content failed on
|
|
2
|
+
// the server is shown over the page: what failed, and where in the templates.
|
|
3
|
+
// Production shows nothing of the error; the boundary keeps its fallback.
|
|
4
|
+
//
|
|
5
|
+
// The boundary hands the error to the handler ./suspense-boundary.js was given
|
|
6
|
+
// (the bootstrap installs one before the first render, the router keeps it).
|
|
7
|
+
// React's development client rebuilds the server's backtrace into the error's
|
|
8
|
+
// `stack`.
|
|
9
|
+
|
|
10
|
+
const OVERLAY_ID = "ruact-dev-overlay";
|
|
11
|
+
|
|
12
|
+
/** Shows +error+ over the page; a click closes it. */
|
|
13
|
+
export function showDevOverlay(error, doc = globalThis.document) {
|
|
14
|
+
const cause = error?.cause ?? error;
|
|
15
|
+
let overlay = doc.getElementById(OVERLAY_ID);
|
|
16
|
+
if (!overlay) {
|
|
17
|
+
overlay = doc.createElement("div");
|
|
18
|
+
overlay.id = OVERLAY_ID;
|
|
19
|
+
overlay.setAttribute("role", "alertdialog");
|
|
20
|
+
overlay.setAttribute("aria-label", "A <Suspense> failed on the server");
|
|
21
|
+
overlay.style.cssText =
|
|
22
|
+
"position:fixed;inset:0;z-index:2147483647;background:rgba(24,6,6,.94);color:#fff;" +
|
|
23
|
+
"font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;padding:32px;overflow:auto;" +
|
|
24
|
+
"white-space:pre-wrap;cursor:pointer";
|
|
25
|
+
overlay.addEventListener("click", () => overlay.remove());
|
|
26
|
+
(doc.body ?? doc.documentElement).appendChild(overlay);
|
|
27
|
+
}
|
|
28
|
+
const title = doc.createElement("h2");
|
|
29
|
+
title.textContent = "[ruact] A <Suspense> failed on the server";
|
|
30
|
+
const message = doc.createElement("p");
|
|
31
|
+
message.dataset.overlayMessage = "";
|
|
32
|
+
message.textContent = cause?.message ?? String(cause);
|
|
33
|
+
const stack = doc.createElement("pre");
|
|
34
|
+
stack.dataset.overlayStack = "";
|
|
35
|
+
stack.textContent = cause?.stack ?? "";
|
|
36
|
+
const hint = doc.createElement("p");
|
|
37
|
+
hint.textContent = "Development only. Click to close.";
|
|
38
|
+
overlay.replaceChildren(title, message, stack, hint);
|
|
39
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// How a page is opened from its Flight bytes, wherever they come from: the
|
|
2
|
+
// rows the document pushes onto `__FLIGHT_DATA` (the first load) or the body of
|
|
3
|
+
// the router's fetch (a navigation, a form, revalidate()).
|
|
4
|
+
//
|
|
5
|
+
// The bytes go to React's Flight client through ./transport.js, so a response
|
|
6
|
+
// cut short reaches the Suspense boundaries labelled, and a second reader
|
|
7
|
+
// settles when the last byte is in.
|
|
8
|
+
import { createFromReadableStream } from "virtual:ruact/flight-client";
|
|
9
|
+
import { labelledStream } from "./transport.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The rows the document pushes onto `__FLIGHT_DATA`, as a byte stream that
|
|
13
|
+
* stays open while the document does (Story 19-1): the rows already in the
|
|
14
|
+
* queue, then each one pushed later — a page that streams pushes one script per
|
|
15
|
+
* `<Suspense>` — until the document has finished loading. A row pushed before
|
|
16
|
+
* this runs is in the queue, and read first.
|
|
17
|
+
*/
|
|
18
|
+
export function documentRows(queue, doc = globalThis.document) {
|
|
19
|
+
const encoder = new TextEncoder();
|
|
20
|
+
return new ReadableStream({
|
|
21
|
+
start(controller) {
|
|
22
|
+
let open = true;
|
|
23
|
+
const read = (row) => controller.enqueue(encoder.encode(row));
|
|
24
|
+
queue.forEach(read);
|
|
25
|
+
queue.length = 0;
|
|
26
|
+
queue.push = (...rows) => {
|
|
27
|
+
if (open) rows.forEach(read);
|
|
28
|
+
return 0;
|
|
29
|
+
};
|
|
30
|
+
const close = () => {
|
|
31
|
+
open = false;
|
|
32
|
+
controller.close();
|
|
33
|
+
};
|
|
34
|
+
if (doc?.readyState === "loading") {
|
|
35
|
+
doc.addEventListener("DOMContentLoaded", close, { once: true });
|
|
36
|
+
} else {
|
|
37
|
+
close();
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The element the page mounts into. The entry is `async` and may run before
|
|
45
|
+
* the parser reaches `<div id="root">` — a layout that puts `ruact_js_assets`
|
|
46
|
+
* above it — so this waits for it, and gives up (null) when the document ends
|
|
47
|
+
* without one.
|
|
48
|
+
*/
|
|
49
|
+
export function whenRoot(doc = globalThis.document) {
|
|
50
|
+
const found = doc.getElementById("root");
|
|
51
|
+
if (found || doc.readyState !== "loading") return Promise.resolve(found);
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const observer = new MutationObserver(() => {
|
|
54
|
+
const root = doc.getElementById("root");
|
|
55
|
+
if (root) finish(root);
|
|
56
|
+
});
|
|
57
|
+
const finish = (root) => {
|
|
58
|
+
observer.disconnect();
|
|
59
|
+
doc.removeEventListener("DOMContentLoaded", atEnd);
|
|
60
|
+
resolve(root);
|
|
61
|
+
};
|
|
62
|
+
const atEnd = () => finish(doc.getElementById("root"));
|
|
63
|
+
observer.observe(doc.documentElement, { childList: true, subtree: true });
|
|
64
|
+
doc.addEventListener("DOMContentLoaded", atEnd);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Opens a page from a byte stream.
|
|
70
|
+
*
|
|
71
|
+
* `root` resolves with the tree as soon as row 0 has arrived; deferred Suspense
|
|
72
|
+
* rows keep streaming into it. A stream that ends without a root rejects it
|
|
73
|
+
* (./transport.js labels the failure). `end` settles when the last byte is in,
|
|
74
|
+
* and rejects with the stream's error; it never goes unhandled on its own.
|
|
75
|
+
*/
|
|
76
|
+
export function openPage(body) {
|
|
77
|
+
const [forClient, forEnd] = body.tee();
|
|
78
|
+
const end = drain(forEnd);
|
|
79
|
+
end.catch(() => {});
|
|
80
|
+
// React's client returns a thenable; a Promise is what callers can await,
|
|
81
|
+
// chain and assert on.
|
|
82
|
+
return { root: Promise.resolve(createFromReadableStream(labelledStream(forClient))), end };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function drain(stream) {
|
|
86
|
+
const reader = stream.getReader();
|
|
87
|
+
while (!(await reader.read()).done) { /* the client has its own copy */ }
|
|
88
|
+
}
|