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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +7 -7
- data/lib/ruact/cached_blocks.rb +30 -0
- data/lib/ruact/controller/delivery.rb +24 -9
- data/lib/ruact/controller/streaming.rb +64 -0
- data/lib/ruact/controller.rb +7 -5
- data/lib/ruact/declared_content.rb +33 -0
- data/lib/ruact/deferred_render.rb +55 -9
- data/lib/ruact/doctor.rb +24 -1
- data/lib/ruact/erb_preprocessor.rb +8 -2
- data/lib/ruact/flight/react_element.rb +9 -4
- data/lib/ruact/flight/renderer.rb +4 -1
- data/lib/ruact/html_converter.rb +21 -3
- data/lib/ruact/markers.rb +11 -8
- data/lib/ruact/query_watch.rb +106 -0
- data/lib/ruact/render_context.rb +23 -11
- data/lib/ruact/render_pipeline.rb +3 -1
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper/suspense.rb +10 -2
- data/lib/ruact/view_helper.rb +13 -3
- data/lib/ruact.rb +3 -0
- data/vendor/javascript/vite-plugin-ruact/page-mount.test.mjs +10 -2
- data/vendor/javascript/vite-plugin-ruact/page-stream.test.mjs +29 -4
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +185 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/dev-overlay.js +20 -6
- data/vendor/javascript/vite-plugin-ruact/runtime/page-stream.js +23 -3
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +64 -4
- metadata +4 -1
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 — `<!--
|
|
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="
|
|
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
|
|
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
|
|
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('"', '"')}") : ""
|
|
@@ -72,11 +76,10 @@ module Ruact
|
|
|
72
76
|
node[FALLBACK_ATTR] || ""
|
|
73
77
|
end
|
|
74
78
|
|
|
75
|
-
# The
|
|
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
|
-
|
|
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
|
data/lib/ruact/render_context.rb
CHANGED
|
@@ -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,
|
|
18
|
-
# (`t(".key")` and error messages read it)
|
|
19
|
-
|
|
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
|
|
51
|
+
# Keeps a `<Suspense>` block for later and returns its key.
|
|
44
52
|
#
|
|
45
53
|
# @param block [Block]
|
|
46
|
-
# @return [
|
|
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 +
|
|
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(
|
|
56
|
-
|
|
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
|
-
#
|
|
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
|
@@ -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
|
data/lib/ruact/view_helper.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
}
|