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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/CONTRIBUTING.md +10 -3
  4. data/README.md +1 -1
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +13 -13
  6. data/lib/generators/ruact/install/templates/initializer.rb.tt +4 -1
  7. data/lib/ruact/client_manifest.rb +7 -7
  8. data/lib/ruact/configuration.rb +6 -1
  9. data/lib/ruact/controller/delivery.rb +247 -0
  10. data/lib/ruact/controller/document_rendering.rb +15 -12
  11. data/lib/ruact/controller/streaming.rb +241 -0
  12. data/lib/ruact/controller.rb +29 -48
  13. data/lib/ruact/deferred_render.rb +208 -0
  14. data/lib/ruact/doctor.rb +18 -1
  15. data/lib/ruact/erb_preprocessor.rb +203 -22
  16. data/lib/ruact/flight/react_element.rb +17 -2
  17. data/lib/ruact/flight/renderer.rb +91 -41
  18. data/lib/ruact/flight/request.rb +71 -27
  19. data/lib/ruact/flight/serializer.rb +3 -10
  20. data/lib/ruact/html_converter.rb +23 -31
  21. data/lib/ruact/inline_script.rb +24 -0
  22. data/lib/ruact/load_async_note.rb +51 -0
  23. data/lib/ruact/markers.rb +95 -0
  24. data/lib/ruact/navigation_boundary.rb +2 -2
  25. data/lib/ruact/railtie.rb +12 -0
  26. data/lib/ruact/render_context.rb +61 -6
  27. data/lib/ruact/render_pipeline.rb +29 -21
  28. data/lib/ruact/request_kind.rb +36 -0
  29. data/lib/ruact/server.rb +3 -7
  30. data/lib/ruact/testing/flight_extractor.rb +21 -4
  31. data/lib/ruact/version.rb +1 -1
  32. data/lib/ruact/view_helper/suspense.rb +62 -0
  33. data/lib/ruact/view_helper.rb +24 -32
  34. data/lib/ruact.rb +4 -0
  35. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +16 -3
  36. data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +67 -0
  37. data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +107 -0
  38. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +23 -18
  39. data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +39 -0
  40. data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +88 -0
  41. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +11 -17
  42. data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +1 -0
  43. 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
@@ -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
- token = ctx.register(name, props)
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
- token = ctx.register(name, props)
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
- # The payload carries application data (props, record content), so it is
233
- # embedded as a JSON string literal with `<`, `>`, `&`, U+2028 and U+2029
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(#{__ruact_script_safe_json(flight_payload)});
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
- react_preamble = <<~JS
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(/import '\/.*\/runtime\/suspense-boundary\.js'/);
59
+ expect(src).toMatch(/from '\/.*\/runtime\/suspense-boundary\.js'/);
60
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'");
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
- import { labelledStream } from './transport.js';
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
- // The inline payload, as the stream the client reads. The root is mounted
60
- // once the tree resolves, so the first paint is the page, not a fallback.
61
- const bytes = new TextEncoder().encode(flightData.join(''));
62
- const stream = new ReadableStream({
63
- start(controller) {
64
- controller.enqueue(bytes);
65
- controller.close();
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 = createFromReadableStream(labelledStream(stream));
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(document.getElementById('root'), { onCaughtError }).render(createElement(App));
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
+ }