ruact 0.0.20 → 0.0.21

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.
data/lib/ruact/markers.rb CHANGED
@@ -8,14 +8,18 @@ module Ruact
8
8
  # Three markers:
9
9
  #
10
10
  # - a client component: an HTML comment holding the token its
11
- # {RenderContext} entry was registered under — `<!-- __RUACT_0__ -->`;
11
+ # {RenderContext} entry was registered under — `<!-- __RUACT_a1b2c3d4_0__ -->`;
12
12
  # - a client component with children: an element wrapped around the
13
- # children's HTML — `<ruact-component data-ruact-token="__RUACT_0__">…`;
13
+ # children's HTML — `<ruact-component data-ruact-token="__RUACT_a1b2c3d4_0__">…`;
14
14
  # - a Suspense boundary: an element wrapped around its content, carrying the
15
15
  # fallback text and the optional delay —
16
16
  # `<ruact-suspense data-ruact-fallback="Loading…">…</ruact-suspense>`. A
17
- # boundary whose content is rendered later is empty and carries the index
18
- # of its block in the {RenderContext} instead (`data-ruact-block="0"`).
17
+ # boundary whose content is rendered later is empty and carries the key
18
+ # of its block in the {RenderContext} instead (`data-ruact-block="a1b2c3d4-0"`).
19
+ #
20
+ # Every token and key carries the id of the render that wrote it, so a
21
+ # marker a `cache` block stored in another render is never taken for one of
22
+ # this render's.
19
23
  #
20
24
  # {ErbPreprocessor} writes the ERB that produces them, the view helpers
21
25
  # ({ViewHelper}, and {RenderPipeline} for a plain-ERB render) write the
@@ -49,7 +53,7 @@ module Ruact
49
53
  # attribute values as written in the template. Only `"` is escaped: the
50
54
  # rest of the text stays as the template wrote it (entities included, which
51
55
  # the HTML parser then decodes). The preprocessor refuses ERB in either.
52
- # +block+ is the index of the boundary's content in the render context,
56
+ # +block+ is the key of the boundary's content in the render context,
53
57
  # when that content is rendered later.
54
58
  def self.suspense_open(fallback, delay, block: nil)
55
59
  delay_attr = delay ? %( #{DELAY_ATTR}="#{delay.gsub('"', '&quot;')}") : ""
@@ -72,11 +76,10 @@ module Ruact
72
76
  node[FALLBACK_ATTR] || ""
73
77
  end
74
78
 
75
- # The index of the block a Suspense wrapper's content is rendered from
79
+ # The key of the block a Suspense wrapper's content is rendered from
76
80
  # later, or nil when the content is in the wrapper.
77
81
  def self.suspense_block(node)
78
- value = node[BLOCK_ATTR]
79
- value && Integer(value, 10)
82
+ node[BLOCK_ATTR]
80
83
  end
81
84
 
82
85
  # The delay of a Suspense wrapper, in seconds, or nil when it is absent,
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ # Story 19-2 (AC14) — when a `<Suspense>` runs past `suspense_timeout` with a
5
+ # query in flight, that query is cancelled on the database server.
6
+ #
7
+ # `Timeout` gives the block back at the limit, but a query the driver is
8
+ # waiting on keeps running on the server, and the connection stays busy with
9
+ # it: the next query on it — this response's, or the next request's once the
10
+ # connection is back in the pool — waits for it to end (measured with
11
+ # PostgreSQL: seconds). Inside a transaction even the unwinding waits, at its
12
+ # ROLLBACK. So at the limit the queries in flight on the connections the
13
+ # block's thread holds — or that it already gave back — are cancelled, from
14
+ # another thread if the block is stuck; the connection is free again within
15
+ # milliseconds.
16
+ #
17
+ # PostgreSQL only: `PG::Connection#cancel` is libpq's PQcancel, made to be
18
+ # called from another thread. Other adapters are left as they are; the
19
+ # database's own statement timeout is the limit there.
20
+ #
21
+ # Not cancelled: a query `load_async` runs. The async executor's thread holds
22
+ # its connection, not the block's, and nothing ties that query to the block
23
+ # that waits for it; it runs to its end. The statement timeout is the limit
24
+ # for those.
25
+ #
26
+ # Internal API: not part of the public compatibility contract.
27
+ class QueryWatch
28
+ # Who holds a connection the way Active Record records it: the thread, or
29
+ # the fiber under `isolation_level = :fiber`.
30
+ def self.current_owner
31
+ ::ActiveSupport::IsolatedExecutionState.context
32
+ end
33
+
34
+ # Watches +owner+'s connections for +limit+ seconds; nil when there is
35
+ # nothing to watch (no Active Record).
36
+ def self.start(owner, limit)
37
+ return nil unless defined?(::ActiveRecord::Base) && defined?(::PG::Connection)
38
+
39
+ new(owner, limit)
40
+ end
41
+
42
+ def initialize(owner, limit)
43
+ @lock = Mutex.new
44
+ @done = false
45
+ @cancelled = false
46
+ @watcher = Thread.new do
47
+ sleep(limit)
48
+ @lock.synchronize { cancel_queries_of(owner) unless @done }
49
+ end
50
+ end
51
+
52
+ # The block ran past the limit and was given back: cancel what its owner
53
+ # still has in flight now, whether or not the watcher got there first.
54
+ def cancel_now(owner)
55
+ @lock.synchronize { cancel_queries_of(owner) }
56
+ end
57
+
58
+ # Whether a query was cancelled: the error the block then raises
59
+ # (`ActiveRecord::QueryCanceled`) is the timeout's, not the content's. The
60
+ # watcher can get there before `Timeout` does.
61
+ def cancelled?
62
+ @cancelled
63
+ end
64
+
65
+ # The block ended (or was ended): nothing to cancel any more. Does not wait
66
+ # for a cancel under way, which opens a connection to the database.
67
+ def stop
68
+ @done = true
69
+ @watcher.kill
70
+ end
71
+
72
+ private
73
+
74
+ # The queries are found under each pool's lock and cancelled outside it: a
75
+ # cancel opens a connection to the database, and the pool's lock is every
76
+ # thread's checkout. A connection nobody holds could be handed to another
77
+ # request in between and have that request's query cancelled; the window
78
+ # is the time a cancel takes to reach the server, on the timeout path only.
79
+ def cancel_queries_of(owner)
80
+ in_flight(owner).each do |raw|
81
+ raw.cancel
82
+ @cancelled = true
83
+ rescue StandardError
84
+ nil
85
+ end
86
+ end
87
+
88
+ def in_flight(owner)
89
+ ::ActiveRecord::Base.connection_handler.connection_pool_list(:all).flat_map do |pool|
90
+ pool.synchronize { pool.connections.filter_map { |connection| query_of(connection, owner) } }
91
+ rescue StandardError
92
+ []
93
+ end
94
+ end
95
+
96
+ # The owner's own connection, or one already back in the pool: unwinding a
97
+ # `with_connection` gives the connection back with its query still running.
98
+ # A query in flight on a connection nobody holds is one nobody waits for.
99
+ def query_of(connection, owner)
100
+ return unless connection.owner.nil? || connection.owner.equal?(owner)
101
+
102
+ raw = connection.instance_variable_get(:@raw_connection)
103
+ raw if raw.is_a?(::PG::Connection) && raw.transaction_status == ::PG::PQTRANS_ACTIVE
104
+ end
105
+ end
106
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Ruact
4
6
  # Per-render mutable state holding the components encountered during ERB
5
7
  # evaluation, and — in an action that streams — the `<Suspense>` blocks whose
@@ -14,13 +16,19 @@ module Ruact
14
16
  # Internal API: not part of the public compatibility contract.
15
17
  class RenderContext
16
18
  # A `<Suspense>` block waiting to be rendered: the view it was written in,
17
- # the block, and the template Rails was rendering when it met the tag
18
- # (`t(".key")` and error messages read it).
19
- Block = Struct.new(:view, :block, :virtual_path, :template, :location)
19
+ # the block, the template Rails was rendering when it met the tag
20
+ # (`t(".key")` and error messages read it), and the local variables the
21
+ # block can see, as they were then (Story 19-2: a `for` loop reuses one
22
+ # variable, a local can be reassigned below the tag).
23
+ Block = Struct.new(:view, :block, :virtual_path, :template, :location, :locals)
20
24
 
21
25
  # @param deferring [Boolean] whether a `<Suspense>`'s content is rendered
22
26
  # after the shell (an action that streams) instead of in place
23
27
  def initialize(deferring: false)
28
+ # Story 19-2 — every marker this render writes carries it, so a marker a
29
+ # `cache` block stored in another render never passes for one of this
30
+ # render's: it would name another component, or another boundary's block.
31
+ @id = SecureRandom.hex(4)
24
32
  @components = []
25
33
  @deferring = deferring
26
34
  @blocks = []
@@ -35,25 +43,29 @@ module Ruact
35
43
 
36
44
  def register(name, props)
37
45
  # Frozen: the converter keys a Hash by it, which would copy an unfrozen one.
38
- token = "__RUACT_#{@components.length}__".freeze
46
+ token = "__RUACT_#{@id}_#{@components.length}__".freeze
39
47
  @components << { token: token, name: name, props: props }
40
48
  token
41
49
  end
42
50
 
43
- # Keeps a `<Suspense>` block for later and returns its index.
51
+ # Keeps a `<Suspense>` block for later and returns its key.
44
52
  #
45
53
  # @param block [Block]
46
- # @return [Integer]
54
+ # @return [String]
47
55
  def defer_block(block)
48
56
  @blocks << block
49
- @blocks.length - 1
57
+ "#{@id}-#{@blocks.length - 1}".freeze
50
58
  end
51
59
 
52
- # The block registered under +index+.
60
+ # The block registered under +key+, or nil when the key is not this
61
+ # render's — a marker that came out of a `cache` block another render wrote.
53
62
  #
54
- # @return [Block]
55
- def block(index)
56
- @blocks.fetch(index)
63
+ # @return [Block, nil]
64
+ def block(key)
65
+ id, index = key.to_s.split("-", 2)
66
+ return nil unless id == @id && index.to_s.match?(/\A\d+\z/)
67
+
68
+ @blocks[Integer(index, 10)]
57
69
  end
58
70
 
59
71
  # Whether the development message for a `<Suspense>` in an action that
@@ -66,7 +66,8 @@ module Ruact
66
66
  #
67
67
  # @example Pre-rendered HTML, streaming output
68
68
  # ctx = Ruact::RenderContext.new
69
- # pipeline.render({ html: "<!-- __RUACT_0__ -->", render_context: ctx }, mode: :stream)
69
+ # html = Ruact::Markers.component(ctx.register("NavBar", {}))
70
+ # pipeline.render({ html: html, render_context: ctx }, mode: :stream)
70
71
  # # => #<Enumerator: ...> (yields Flight rows lazily)
71
72
  def render(input, mode: :string)
72
73
  validate_mode!(mode)
@@ -232,6 +233,7 @@ module Ruact
232
233
  # the caller may discard the RenderContext immediately after `render` returns — the
233
234
  # returned Enumerator does not reference the RenderContext.
234
235
  def render_html_enum(html, render_context, streaming:)
236
+ CachedBlocks.report(html, @logger) unless render_context.deferring?
235
237
  registry = render_context.resolve(@manifest, controller_path: @controller_path)
236
238
  strict = Ruact.config.strict_serialization
237
239
  warning_cb = as_json_warning_callback
data/lib/ruact/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruact
4
- VERSION = "0.0.20"
4
+ VERSION = "0.0.21"
5
5
  end
@@ -21,7 +21,7 @@ module Ruact
21
21
  ctx = @ruact_render_context
22
22
  if ctx&.deferring?
23
23
  index = ctx.defer_block(RenderContext::Block.new(self, block, @virtual_path, @current_template,
24
- caller_locations(1, 1).first))
24
+ caller_locations(1, 1).first, __ruact_locals(block)))
25
25
  return "#{Markers.suspense_open(fallback, delay, block: index)}#{Markers::SUSPENSE_CLOSE}".html_safe
26
26
  end
27
27
 
@@ -33,6 +33,14 @@ module Ruact
33
33
  "#{Markers.suspense_open(fallback, delay)}#{content}#{Markers::SUSPENSE_CLOSE}".html_safe
34
34
  end
35
35
 
36
+ # Story 19-2 (AC8.3) — the local variables +block+ sees, as they are when
37
+ # the tag is met. The block runs later; without these it would see a local
38
+ # as it is then — the last item of a `for` loop, a value reassigned below.
39
+ def __ruact_locals(block)
40
+ scope = block.binding
41
+ scope.local_variables.to_h { |name| [name, scope.local_variable_get(name)] }
42
+ end
43
+
36
44
  # Story 19-1 — a `<Suspense>` in an action that does not stream renders its
37
45
  # content before the page is sent, as it always did. Development says so,
38
46
  # once per response, with the line that makes the action stream.
@@ -56,7 +64,7 @@ module Ruact
56
64
  )
57
65
  end
58
66
 
59
- private :__ruact_note_unstreamed_suspense
67
+ private :__ruact_note_unstreamed_suspense, :__ruact_locals
60
68
  end
61
69
  end
62
70
  end
@@ -235,7 +235,7 @@ module Ruact
235
235
  # `</script>`, and its `\e`-style escapes mean something else in JS).
236
236
  def ruact_flight_data_script(flight_payload)
237
237
  <<~HTML.strip
238
- <script>
238
+ <script#{__ruact_nonce_attr}>
239
239
  (function() {
240
240
  var d = (self.__FLIGHT_DATA = self.__FLIGHT_DATA || []);
241
241
  d.push(#{InlineScript.string_literal(flight_payload)});
@@ -244,6 +244,16 @@ module Ruact
244
244
  HTML
245
245
  end
246
246
 
247
+ # Story 19-2 (AC21) — the request's CSP nonce as a `<script>` attribute, so
248
+ # an app whose policy allows scripts by nonce (no `'unsafe-inline'`) runs
249
+ # the ones ruact writes. Empty without a nonce generator: the bytes are
250
+ # those of before.
251
+ def __ruact_nonce_attr
252
+ source = respond_to?(:request, true) ? request : nil
253
+ nonce = source.content_security_policy_nonce if source.respond_to?(:content_security_policy_nonce)
254
+ nonce ? %( nonce="#{ERB::Util.html_escape(nonce)}") : ""
255
+ end
256
+
247
257
  # The bootstrap entry `<script>` tags — dev serves the virtual module from
248
258
  # the Vite dev server (with the react-refresh preamble + `@vite/client`);
249
259
  # prod reads the hashed URL from the Vite manifest. Both target the SAME
@@ -264,7 +274,7 @@ module Ruact
264
274
  # bootstrap, in that order.
265
275
  dev_server = Ruact.config.vite_dev_server.chomp("/")
266
276
  <<~HTML
267
- <script type="module" async>
277
+ <script type="module" async#{__ruact_nonce_attr}>
268
278
  import RefreshRuntime from '#{dev_server}/@react-refresh';
269
279
  RefreshRuntime.injectIntoGlobalHook(window);
270
280
  window.$RefreshReg$ = () => {};
@@ -279,7 +289,7 @@ module Ruact
279
289
  # virtual entry id (the manifest key Vite emits for the virtual input).
280
290
  entry = vite_manifest_entry(Ruact.bootstrap_virtual_id)
281
291
  src = entry ? "/assets/#{entry['file']}" : "/assets/application.js"
282
- tag = %(<script type="module" async src="#{src}"></script>)
292
+ tag = %(<script type="module" async src="#{src}"#{__ruact_nonce_attr}></script>)
283
293
  # Story 17-0k — in development with neither the Vite dev server nor a
284
294
  # build, that script 404s and the page stays empty with no error.
285
295
  Rails.env.development? && entry.nil? ? "#{ruact_vite_down_warning}\n#{tag}" : tag
data/lib/ruact.rb CHANGED
@@ -20,6 +20,9 @@ require_relative "ruact/client_manifest"
20
20
  require_relative "ruact/manifest_resolver"
21
21
  require_relative "ruact/render_pipeline"
22
22
  require_relative "ruact/deferred_render"
23
+ require_relative "ruact/cached_blocks"
24
+ require_relative "ruact/query_watch"
25
+ require_relative "ruact/declared_content"
23
26
  require_relative "ruact/view_helper"
24
27
  require_relative "ruact/erb_preprocessor_hook"
25
28
  require_relative "ruact/server_functions"
@@ -56,12 +56,20 @@ describe("showDevOverlay", () => {
56
56
  expect(document.getElementById("ruact-dev-overlay")).toBeNull();
57
57
  });
58
58
 
59
- it("shows the latest failure in the one overlay", () => {
59
+ // Story 19-2 (AC7) — every failure, in the one overlay.
60
+ it("shows each failure in the one overlay, and opens again after it was closed", () => {
60
61
  showDevOverlay(new Error("first"));
61
62
  showDevOverlay(new Error("second"));
62
63
  const overlays = document.querySelectorAll("#ruact-dev-overlay");
63
64
  expect(overlays).toHaveLength(1);
64
- expect(overlays[0].querySelector("[data-overlay-message]").textContent).toBe("second");
65
+ const messages = [...overlays[0].querySelectorAll("[data-overlay-message]")].map((el) => el.textContent);
66
+ expect(messages).toEqual(["first", "second"]);
67
+ expect(overlays[0].querySelector("[data-overlay-title]").textContent).toBe("[ruact] 2 <Suspense> failed on the server");
65
68
  overlays[0].click();
69
+
70
+ showDevOverlay(new Error("third"));
71
+ const reopened = document.getElementById("ruact-dev-overlay");
72
+ expect([...reopened.querySelectorAll("[data-overlay-message]")].map((el) => el.textContent)).toEqual(["third"]);
73
+ reopened.click();
66
74
  });
67
75
  });
@@ -1,7 +1,7 @@
1
1
  // The two ways a page is opened read the same way: the rows a document inlined
2
2
  // in `__FLIGHT_DATA`, and a fetch body that may end early.
3
3
  import { describe, it, expect } from "vitest";
4
- import { documentRows, openPage } from "./runtime/page-stream.js";
4
+ import { LEAVE_EVENT, documentRows, openPage } from "./runtime/page-stream.js";
5
5
 
6
6
  const encoder = new TextEncoder();
7
7
 
@@ -57,9 +57,14 @@ describe("documentRows", () => {
57
57
  return {
58
58
  readyState: "loading",
59
59
  addEventListener(type, listener) { listeners[type] = listener; },
60
- finish() {
61
- this.readyState = "interactive";
62
- listeners.DOMContentLoaded?.();
60
+ removeEventListener(type) { delete listeners[type]; },
61
+ finish(state = "interactive") {
62
+ this.readyState = state;
63
+ listeners.readystatechange?.();
64
+ },
65
+ leave() {
66
+ listeners[LEAVE_EVENT]?.();
67
+ this.finish("complete");
63
68
  },
64
69
  };
65
70
  }
@@ -93,6 +98,26 @@ describe("documentRows", () => {
93
98
  expect(closed).toBe(true);
94
99
  });
95
100
 
101
+ // Story 19-2 — the router stops a document it leaves while it still loads.
102
+ it("closes when the document is stopped while loading", async () => {
103
+ const doc = openDocument();
104
+ const reader = documentRows(['0:"x"\n'], doc).getReader();
105
+ await reader.read();
106
+ doc.finish("complete");
107
+ await expect(reader.read()).resolves.toMatchObject({ done: true });
108
+ });
109
+
110
+ // Story 19-2 — the router leaving it: the page being left reports nothing,
111
+ // as for a superseded navigation.
112
+ it("ends as an aborted navigation when the router leaves the document", async () => {
113
+ const doc = openDocument();
114
+ const page = openPage(documentRows(['0:{"later":"$@1"}\n'], doc));
115
+ const root = await page.root;
116
+ doc.leave();
117
+ await expect(root.later).rejects.toMatchObject({ name: "AbortError" });
118
+ await expect(page.end).rejects.toMatchObject({ name: "AbortError" });
119
+ });
120
+
96
121
  it("closes at once when the document has already ended", async () => {
97
122
  const reader = documentRows(['0:"x"\n'], { readyState: "complete" }).getReader();
98
123
  await reader.read();
@@ -14,6 +14,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
14
14
  import fs from "node:fs";
15
15
  import path from "node:path";
16
16
  import { setupRouter, teardownRouter } from "./runtime/ruact-router.js";
17
+ import { LEAVE_EVENT } from "./runtime/page-stream.js";
17
18
 
18
19
  const ORIGIN = "http://localhost:3000";
19
20
 
@@ -568,3 +569,187 @@ describe("ruact-router — dates and big integers in a navigation (Story 17-0c)"
568
569
  });
569
570
  });
570
571
 
572
+
573
+ // Story 19-2 — leaving a page whose document is still arriving (a page that
574
+ // streams) stops loading it, so the server starts no further block for it.
575
+ describe("leaving a document that is still loading", () => {
576
+ let dom;
577
+
578
+ beforeEach(() => {
579
+ dom = installDom();
580
+ globalThis.fetch = vi.fn().mockResolvedValue(respond({ body: '0:"next"\n' }));
581
+ globalThis.window.stop = vi.fn();
582
+ globalThis.__FLIGHT_DATA = Object.assign([], { streaming: true });
583
+ vi.spyOn(console, "error").mockImplementation(() => {});
584
+ setupRouter({ onNavigate: vi.fn(), moduleRegistry: {}, onError: vi.fn() });
585
+ });
586
+
587
+ afterEach(() => {
588
+ teardownRouter();
589
+ delete globalThis.__FLIGHT_DATA;
590
+ vi.restoreAllMocks();
591
+ });
592
+
593
+ // A slow network, a long layout: the rest of the document after `#root`
594
+ // (which a navigation does not replace) still has to arrive.
595
+ it("leaves a loading document alone when it does not stream, or its rows are all in", async () => {
596
+ globalThis.document.readyState = "loading";
597
+ globalThis.__FLIGHT_DATA = [];
598
+ click(dom.listeners, "/products/2");
599
+ await settle();
600
+ globalThis.__FLIGHT_DATA = Object.assign([], { streaming: false });
601
+ click(dom.listeners, "/products/3");
602
+ await settle();
603
+ expect(window.stop).not.toHaveBeenCalled();
604
+ expect(fetch).toHaveBeenCalledTimes(2);
605
+ });
606
+
607
+ it("stops the document before it fetches the next page", async () => {
608
+ globalThis.document.readyState = "loading";
609
+ let stoppedBeforeFetch = null;
610
+ globalThis.fetch.mockImplementation(async () => {
611
+ stoppedBeforeFetch = window.stop.mock.calls.length === 1;
612
+ return respond({ body: '0:"next"\n' });
613
+ });
614
+ click(dom.listeners, "/products/2");
615
+ await settle();
616
+ expect(stoppedBeforeFetch).toBe(true);
617
+ });
618
+
619
+ it("leaves a document that has finished alone", async () => {
620
+ globalThis.document.readyState = "complete";
621
+ click(dom.listeners, "/products/2");
622
+ await settle();
623
+ expect(window.stop).not.toHaveBeenCalled();
624
+ expect(fetch).toHaveBeenCalled();
625
+ });
626
+
627
+ it("stops it for a form the router submits", async () => {
628
+ globalThis.document.readyState = "loading";
629
+ let stoppedBeforeFetch = null;
630
+ globalThis.fetch.mockImplementation(async () => {
631
+ stoppedBeforeFetch = window.stop.mock.calls.length === 1;
632
+ return respond({ body: '0:"next"\n' });
633
+ });
634
+ submit(dom.listeners, new dom.FakeForm({ action: "/people", method: "post" }));
635
+ await settle();
636
+ expect(stoppedBeforeFetch).toBe(true);
637
+ });
638
+
639
+ // Review 19-2a R3/R4 — a `#anchor` on the page on screen: the browser
640
+ // scrolls; nothing is fetched, and the document is not left.
641
+ it("leaves a popstate that only changes the hash to the browser", async () => {
642
+ globalThis.document.readyState = "loading";
643
+ location._set("/products/1#reviews");
644
+ dom.listeners.window.popstate.forEach((fn) => fn({}));
645
+ await settle();
646
+ expect(window.stop).not.toHaveBeenCalled();
647
+ expect(fetch).not.toHaveBeenCalled();
648
+ });
649
+
650
+ it("leaves a link to this page's #anchor to the browser", async () => {
651
+ globalThis.document.readyState = "loading";
652
+ const event = click(dom.listeners, "/products/1#reviews");
653
+ await settle();
654
+ expect(event.defaultPrevented).toBe(false);
655
+ expect(window.stop).not.toHaveBeenCalled();
656
+ expect(fetch).not.toHaveBeenCalled();
657
+ });
658
+
659
+ // Review 19-2a R4 — as a browser does: once stopped, the document no longer
660
+ // loads, so a later navigation does not stop it again.
661
+ function stopForReal() {
662
+ globalThis.document.readyState = "loading";
663
+ window.stop.mockImplementation(() => { globalThis.document.readyState = "complete"; });
664
+ }
665
+
666
+ it("fetches the page again when the navigation that superseded the one that stopped it fails", async () => {
667
+ stopForReal();
668
+ const html500 = respond({ status: 500, contentType: "text/html", body: "<p>boom</p>" });
669
+ globalThis.fetch
670
+ .mockImplementationOnce((_url, { signal }) => new Promise((_resolve, reject) => {
671
+ signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
672
+ }))
673
+ .mockResolvedValueOnce(html500)
674
+ .mockResolvedValue(respond({ body: '0:"again"\n' }));
675
+ const form = new dom.FakeForm({ action: "/people", method: "post" });
676
+ submit(dom.listeners, form);
677
+ submit(dom.listeners, form);
678
+ await settle();
679
+ await settle();
680
+ expect(window.stop).toHaveBeenCalledTimes(1);
681
+ expect(fetch.mock.calls.map((call) => call[0])).toEqual(["/people", "/people", "/products/1"]);
682
+ });
683
+
684
+ it("fetches the page again when the redirect a form was answered with fails", async () => {
685
+ stopForReal();
686
+ globalThis.fetch
687
+ .mockResolvedValueOnce(respond({ body: '0:{"redirectUrl":"/people/9","redirectType":"push"}\n' }))
688
+ .mockRejectedValueOnce(new TypeError("Failed to fetch"))
689
+ .mockResolvedValue(respond({ body: '0:"again"\n' }));
690
+ submit(dom.listeners, new dom.FakeForm({ action: "/people", method: "post" }));
691
+ await settle();
692
+ await settle();
693
+ expect(fetch.mock.calls.map((call) => call[0])).toEqual(["/people", "/people/9", "/products/1"]);
694
+ });
695
+
696
+ it("does not fetch the page again once a navigation replaced it", async () => {
697
+ stopForReal();
698
+ globalThis.fetch.mockResolvedValue(respond({ body: '0:"next"\n' }));
699
+ click(dom.listeners, "/products/2");
700
+ await settle();
701
+ globalThis.fetch.mockRejectedValue(new TypeError("Failed to fetch"));
702
+ click(dom.listeners, "/products/3");
703
+ await settle();
704
+ await settle();
705
+ expect(fetch.mock.calls.map((call) => call[0])).toEqual(["/products/2", "/products/3"]);
706
+ });
707
+
708
+ // Review 19-2a R3 — the navigation that stopped the document failed: the user
709
+ // is still on that page, whose boundaries would keep their fallback.
710
+ it("fetches the page again when the navigation that stopped it fails", async () => {
711
+ globalThis.document.readyState = "loading";
712
+ globalThis.fetch
713
+ .mockRejectedValueOnce(new TypeError("Failed to fetch"))
714
+ .mockResolvedValue(respond({ body: '0:"again"\n' }));
715
+ submit(dom.listeners, new dom.FakeForm({ action: "/people", method: "post" }));
716
+ await settle();
717
+ await settle();
718
+ expect(window.stop).toHaveBeenCalledTimes(1);
719
+ expect(fetch).toHaveBeenCalledTimes(2);
720
+ expect(fetch.mock.calls[1][0]).toBe("/products/1");
721
+ });
722
+
723
+ it("does not fetch the page again after a failure when nothing was stopped", async () => {
724
+ globalThis.document.readyState = "complete";
725
+ globalThis.fetch.mockRejectedValue(new TypeError("Failed to fetch"));
726
+ submit(dom.listeners, new dom.FakeForm({ action: "/people", method: "post" }));
727
+ await settle();
728
+ await settle();
729
+ expect(fetch).toHaveBeenCalledTimes(1);
730
+ });
731
+
732
+ // revalidate() stays on the page: stopping the document would also fail the
733
+ // page's other requests in flight (a server function, a useQuery).
734
+ it("leaves the document loading for revalidate()", async () => {
735
+ globalThis.document.readyState = "loading";
736
+ await globalThis.__ruact_revalidate();
737
+ expect(window.stop).not.toHaveBeenCalled();
738
+ expect(fetch).toHaveBeenCalledTimes(1);
739
+ });
740
+
741
+ // Stopped, the document's rows would read as a response cut short, which
742
+ // the page being left reports: they are told first.
743
+ it("tells the document's rows it is left before it stops it", async () => {
744
+ globalThis.document.readyState = "loading";
745
+ const told = [];
746
+ globalThis.document.dispatchEvent = vi.fn((event) => {
747
+ told.push([event.type, window.stop.mock.calls.length]);
748
+ return true;
749
+ });
750
+ click(dom.listeners, "/products/2");
751
+ await settle();
752
+ expect(told).toEqual([[LEAVE_EVENT, 0]]);
753
+ expect(window.stop).toHaveBeenCalledTimes(1);
754
+ });
755
+ });
@@ -9,7 +9,11 @@
9
9
 
10
10
  const OVERLAY_ID = "ruact-dev-overlay";
11
11
 
12
- /** Shows +error+ over the page; a click closes it. */
12
+ /**
13
+ * Shows +error+ over the page, under the ones already shown (Story 19-2: one
14
+ * entry per failed <Suspense>); a click closes the overlay, and the next
15
+ * failure opens it again.
16
+ */
13
17
  export function showDevOverlay(error, doc = globalThis.document) {
14
18
  const cause = error?.cause ?? error;
15
19
  let overlay = doc.getElementById(OVERLAY_ID);
@@ -23,17 +27,27 @@ export function showDevOverlay(error, doc = globalThis.document) {
23
27
  "font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;padding:32px;overflow:auto;" +
24
28
  "white-space:pre-wrap;cursor:pointer";
25
29
  overlay.addEventListener("click", () => overlay.remove());
30
+ const title = doc.createElement("h2");
31
+ title.dataset.overlayTitle = "";
32
+ const hint = doc.createElement("p");
33
+ hint.dataset.overlayHint = "";
34
+ hint.textContent = "Development only. Click to close.";
35
+ overlay.append(title, hint);
26
36
  (doc.body ?? doc.documentElement).appendChild(overlay);
27
37
  }
28
- const title = doc.createElement("h2");
29
- title.textContent = "[ruact] A <Suspense> failed on the server";
38
+
39
+ const entry = doc.createElement("section");
40
+ entry.dataset.overlayEntry = "";
30
41
  const message = doc.createElement("p");
31
42
  message.dataset.overlayMessage = "";
32
43
  message.textContent = cause?.message ?? String(cause);
33
44
  const stack = doc.createElement("pre");
34
45
  stack.dataset.overlayStack = "";
35
46
  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);
47
+ entry.append(message, stack);
48
+ overlay.insertBefore(entry, overlay.querySelector("[data-overlay-hint]"));
49
+
50
+ const count = overlay.querySelectorAll("[data-overlay-entry]").length;
51
+ overlay.querySelector("[data-overlay-title]").textContent =
52
+ count === 1 ? "[ruact] A <Suspense> failed on the server" : `[ruact] ${count} <Suspense> failed on the server`;
39
53
  }