ruact 0.0.13 → 0.0.15
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 +63 -1
- data/lib/generators/ruact/install/install_generator.rb +113 -2
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +1 -1
- data/lib/generators/ruact/install/templates/package.json.tt +4 -4
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +39 -12
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +4 -3
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +8 -8
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +8 -8
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +2 -2
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +1 -1
- data/lib/ruact/configuration.rb +6 -4
- data/lib/ruact/controller/format_negotiation.rb +83 -0
- data/lib/ruact/controller/page_rendering.rb +1 -1
- data/lib/ruact/controller.rb +7 -0
- data/lib/ruact/flight/js_date.rb +22 -0
- data/lib/ruact/flight/renderer.rb +8 -3
- data/lib/ruact/flight/serializer.rb +10 -4
- data/lib/ruact/flight/utf8.rb +36 -0
- data/lib/ruact/flight.rb +2 -0
- data/lib/ruact/redirect_path.rb +16 -0
- data/lib/ruact/routing.rb +24 -6
- data/lib/ruact/server.rb +6 -0
- data/lib/ruact/testing/component_query.rb +23 -1
- data/lib/ruact/testing/flight_extractor.rb +6 -1
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +43 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.js +19 -10
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +7 -0
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +12 -0
- data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +131 -0
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +84 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +167 -5
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +10 -19
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +1 -1
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +3 -3
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +3 -3
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +4 -4
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +4 -4
- metadata +6 -2
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module Controller
|
|
5
|
+
# Story 17-0j — a router request answers as `format.html`.
|
|
6
|
+
#
|
|
7
|
+
# The ruact router asks for a page with `Accept: text/x-component`, which is
|
|
8
|
+
# not a Rails MIME type: the request negotiated no format at all, and every
|
|
9
|
+
# `respond_to` raised `ActionController::UnknownFormat` (406) on a
|
|
10
|
+
# navigation or a form submit. That is the controller Rails' own scaffold
|
|
11
|
+
# writes. A router request asks for the PAGE, the same thing a browser's
|
|
12
|
+
# `text/html` asks for, so it now negotiates `:html` — unless the URL names
|
|
13
|
+
# a format (`/posts/1.json`), which wins as it always has. Whether the
|
|
14
|
+
# answer is Flight or an HTML document is still ruact's decision, read from
|
|
15
|
+
# the Accept header, not from the negotiated format.
|
|
16
|
+
module FormatNegotiation
|
|
17
|
+
extend ActiveSupport::Concern
|
|
18
|
+
|
|
19
|
+
# Rails' negotiated-formats slot on the request. Writing it directly,
|
|
20
|
+
# rather than `request.format =`, leaves `params[:format]` alone.
|
|
21
|
+
FORMATS_KEY = "action_dispatch.request.formats"
|
|
22
|
+
|
|
23
|
+
# `respond_to` with no `format.html` still answers 406 to a router
|
|
24
|
+
# request, as Rails does to a browser — with the reason and the fix. Only
|
|
25
|
+
# when THIS respond_to found no format: an UnknownFormat raised inside a
|
|
26
|
+
# branch (a nested respond_to, an explicit raise), or one for a format
|
|
27
|
+
# the URL named, keeps Rails' own message.
|
|
28
|
+
#
|
|
29
|
+
# @see ActionController::MimeResponds#respond_to
|
|
30
|
+
def respond_to(*mimes, &block)
|
|
31
|
+
collector = nil
|
|
32
|
+
recording = block && lambda { |given|
|
|
33
|
+
collector = given
|
|
34
|
+
yield(given)
|
|
35
|
+
}
|
|
36
|
+
super(*mimes, &recording)
|
|
37
|
+
rescue ActionController::UnknownFormat
|
|
38
|
+
raise unless __ruact_router_found_no_format?(collector, mimes)
|
|
39
|
+
|
|
40
|
+
raise ActionController::UnknownFormat,
|
|
41
|
+
"#{self.class.name}##{action_name} was asked for its page by the ruact router, which " \
|
|
42
|
+
"negotiates format.html, and its respond_to has no format.html branch. Add one, or " \
|
|
43
|
+
"link to this action with data-ruact=\"false\" so the browser loads it."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Before every callback — a host's own `prepend_before_action` included —
|
|
49
|
+
# since this sits above AbstractController::Callbacks in the chain. An
|
|
50
|
+
# auth callback that answers with `respond_to` gets the format too.
|
|
51
|
+
def process_action(...)
|
|
52
|
+
__ruact_negotiate_router_request_as_html
|
|
53
|
+
super
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def __ruact_negotiate_router_request_as_html
|
|
57
|
+
return unless ruact_request?
|
|
58
|
+
return if __ruact_url_format.present?
|
|
59
|
+
|
|
60
|
+
request.set_header(FORMATS_KEY, [Mime[:html]])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The format the URL names (`/posts/1.json`, `?format=json`). Read from
|
|
64
|
+
# the path and query only: this runs before Rails' rescue and logging,
|
|
65
|
+
# where parsing a malformed request BODY must not raise.
|
|
66
|
+
def __ruact_url_format
|
|
67
|
+
request.path_parameters[:format] || request.query_parameters[:format]
|
|
68
|
+
rescue ActionController::BadRequest, ActionDispatch::Http::Parameters::ParseError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def __ruact_router_found_no_format?(collector, mimes)
|
|
73
|
+
return false unless ruact_request? && __ruact_url_format.blank?
|
|
74
|
+
|
|
75
|
+
if collector
|
|
76
|
+
collector.format.nil?
|
|
77
|
+
else
|
|
78
|
+
request.negotiate_mime(mimes.map { |mime| Mime[mime] }).nil?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -98,7 +98,7 @@ module Ruact
|
|
|
98
98
|
# Whether Rails, left alone, would render `page` for `action`: the lookup
|
|
99
99
|
# it does itself, with the formats it negotiated for this request (and a
|
|
100
100
|
# render's own `formats:` / `variants:` / `handlers:` / `locale:`). A Flight request
|
|
101
|
-
# negotiates
|
|
101
|
+
# negotiates HTML (Ruact::Controller::FormatNegotiation) — the page; a `.json` request or a
|
|
102
102
|
# `format.json` branch negotiates JSON — the JSON template, or Rails' own
|
|
103
103
|
# MissingTemplate.
|
|
104
104
|
def __ruact_rails_would_render?(action, details, page)
|
data/lib/ruact/controller.rb
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "socket"
|
|
5
5
|
require "uri"
|
|
6
|
+
require_relative "redirect_path"
|
|
6
7
|
require_relative "view_helper"
|
|
7
8
|
require_relative "validation_errors_collector"
|
|
8
9
|
require_relative "controller/document_rendering"
|
|
9
10
|
require_relative "controller/pages"
|
|
10
11
|
require_relative "controller/page_rendering"
|
|
12
|
+
require_relative "controller/format_negotiation"
|
|
11
13
|
|
|
12
14
|
module Ruact
|
|
13
15
|
# Include in the controllers whose pages render through ruact (island mode,
|
|
@@ -43,6 +45,9 @@ module Ruact
|
|
|
43
45
|
# An explicit `render` of a ruact page goes through ruact (Story 17.0i).
|
|
44
46
|
# See Ruact::Controller::PageRendering.
|
|
45
47
|
include Ruact::Controller::PageRendering
|
|
48
|
+
# A router request negotiates `format.html`, so `respond_to` answers it
|
|
49
|
+
# (Story 17-0j). See Ruact::Controller::FormatNegotiation.
|
|
50
|
+
include Ruact::Controller::FormatNegotiation
|
|
46
51
|
|
|
47
52
|
# Story 17.0f — "is this action a ruact PAGE?", answered at CLASS level so the
|
|
48
53
|
# navigation boundary (Ruact::NavigationBoundary) can ask it before any action
|
|
@@ -266,6 +271,8 @@ module Ruact
|
|
|
266
271
|
redirect_url = uri.path.nil? || uri.path.empty? ? "/" : uri.path
|
|
267
272
|
redirect_url += "?#{uri.query}" if uri.query
|
|
268
273
|
redirect_url += "##{uri.fragment}" if uri.fragment
|
|
274
|
+
# Same-origin `https://app//evil.com` must not shrink to `//evil.com`.
|
|
275
|
+
redirect_url = url if Ruact::RedirectPath.protocol_relative?(redirect_url)
|
|
269
276
|
rescue ::URI::InvalidURIError
|
|
270
277
|
return super
|
|
271
278
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module Flight
|
|
5
|
+
# The ISO 8601 form a JavaScript `Date` reads back (Story 17-0c).
|
|
6
|
+
module JsDate
|
|
7
|
+
# JavaScript parses years 0–9999 in the plain form and the others only in
|
|
8
|
+
# the expanded one (`+010000`, `-000001`); Ruby writes `10000` / `-0001`,
|
|
9
|
+
# which `new Date` turns into an Invalid Date.
|
|
10
|
+
#
|
|
11
|
+
# @param value [Time, DateTime]
|
|
12
|
+
# @return [String] ISO 8601 with milliseconds
|
|
13
|
+
def self.iso8601(value)
|
|
14
|
+
iso = value.iso8601(3)
|
|
15
|
+
year = value.year
|
|
16
|
+
return iso unless year.negative? || year > 9999
|
|
17
|
+
|
|
18
|
+
format("%<sign>s%<year>06d", sign: year.negative? ? "-" : "+", year: year.abs) + iso.sub(/\A-?\d+/, "")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -56,12 +56,17 @@ module Ruact
|
|
|
56
56
|
sleep(deferred[:delay])
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
# Serialize deferred content — may produce new import rows
|
|
60
|
-
|
|
59
|
+
# Serialize deferred content — may produce new import rows, and new
|
|
60
|
+
# regular rows (a string of 1024+ bytes becomes a `T` row the deferred
|
|
61
|
+
# model row references as `$T<id>`).
|
|
62
|
+
import_count_before = @request.completed_import_chunks.length
|
|
63
|
+
regular_count_before = @request.completed_regular_chunks.length
|
|
61
64
|
deferred_value = serializer.serialize_model(deferred[:element])
|
|
62
65
|
|
|
63
|
-
# Yield
|
|
66
|
+
# Yield the rows discovered during deferred serialization BEFORE the
|
|
67
|
+
# row that references them.
|
|
64
68
|
@request.completed_import_chunks[import_count_before..].each(&block)
|
|
69
|
+
@request.completed_regular_chunks[regular_count_before..].each(&block)
|
|
65
70
|
|
|
66
71
|
yield RowEmitter.model(deferred[:id], JSON.generate(deferred_value))
|
|
67
72
|
end
|
|
@@ -33,6 +33,10 @@ module Ruact
|
|
|
33
33
|
serialize_symbol(value)
|
|
34
34
|
when Time, DateTime
|
|
35
35
|
serialize_date(value)
|
|
36
|
+
when Date
|
|
37
|
+
# A calendar date has no instant to rebuild: it travels as
|
|
38
|
+
# "YYYY-MM-DD", in strict mode too (Story 17-0c review).
|
|
39
|
+
serialize_string(value.iso8601)
|
|
36
40
|
when ClientReference
|
|
37
41
|
serialize_client_reference(value)
|
|
38
42
|
when SuspenseElement
|
|
@@ -53,6 +57,7 @@ module Ruact
|
|
|
53
57
|
# --- Primitives ---
|
|
54
58
|
|
|
55
59
|
def serialize_string(value)
|
|
60
|
+
value = Utf8.text(value)
|
|
56
61
|
# Large strings get their own T row
|
|
57
62
|
if value.bytesize >= LARGE_TEXT_THRESHOLD
|
|
58
63
|
id = @request.allocate_id
|
|
@@ -89,12 +94,13 @@ module Ruact
|
|
|
89
94
|
# Only :undefined is special for now
|
|
90
95
|
return "$undefined" if value == :undefined
|
|
91
96
|
|
|
92
|
-
#
|
|
93
|
-
|
|
97
|
+
# Any other symbol is its name — escaped like a string, so `:"$nope"`
|
|
98
|
+
# cannot pose as a "$n…" marker.
|
|
99
|
+
serialize_string(value.to_s)
|
|
94
100
|
end
|
|
95
101
|
|
|
96
102
|
def serialize_date(value)
|
|
97
|
-
"$D#{
|
|
103
|
+
"$D#{JsDate.iso8601(value)}"
|
|
98
104
|
end
|
|
99
105
|
|
|
100
106
|
# --- Collections ---
|
|
@@ -104,7 +110,7 @@ module Ruact
|
|
|
104
110
|
end
|
|
105
111
|
|
|
106
112
|
def serialize_hash(value)
|
|
107
|
-
value.transform_keys(
|
|
113
|
+
value.transform_keys { |key| Utf8.text(key.to_s) }.transform_values { |v| serialize_model(v) }
|
|
108
114
|
end
|
|
109
115
|
|
|
110
116
|
# --- React Element ---
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module Flight
|
|
5
|
+
# Every string leaves the serializer as valid UTF-8. A `T` row is framed by
|
|
6
|
+
# its byte length, and JSON (a model row, the inline page payload) refuses
|
|
7
|
+
# invalid UTF-8: one record holding Latin-1 bytes made its page answer 500
|
|
8
|
+
# for every viewer.
|
|
9
|
+
module Utf8
|
|
10
|
+
REPLACEMENT = "\uFFFD"
|
|
11
|
+
|
|
12
|
+
# Binary-tagged text (`File.binread`, an HTTP body) is retagged; another
|
|
13
|
+
# encoding is transcoded; bytes that are not text in that encoding become
|
|
14
|
+
# U+FFFD, as a browser shows them.
|
|
15
|
+
#
|
|
16
|
+
# @param value [String]
|
|
17
|
+
# @return [String] valid UTF-8 (the same object when it already was)
|
|
18
|
+
def self.text(value)
|
|
19
|
+
return value if value.encoding == Encoding::UTF_8 && value.valid_encoding?
|
|
20
|
+
|
|
21
|
+
text =
|
|
22
|
+
begin
|
|
23
|
+
if [Encoding::BINARY, Encoding::UTF_8].include?(value.encoding)
|
|
24
|
+
value.dup.force_encoding(Encoding::UTF_8)
|
|
25
|
+
else
|
|
26
|
+
value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: REPLACEMENT)
|
|
27
|
+
end
|
|
28
|
+
rescue Encoding::ConverterNotFoundError
|
|
29
|
+
# An encoding Ruby cannot convert from (UTF-7, say): its bytes.
|
|
30
|
+
value.dup.force_encoding(Encoding::UTF_8)
|
|
31
|
+
end
|
|
32
|
+
text.valid_encoding? ? text : text.scrub(REPLACEMENT)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/ruact/flight.rb
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
# Reducing a same-origin redirect URL to its path is what lets the client
|
|
5
|
+
# navigate in place. One path must not be produced that way: `//host` (or
|
|
6
|
+
# `/\host`), which a browser reads as a URL to ANOTHER host. Shared by the
|
|
7
|
+
# page redirect ({Ruact::Controller}) and the server-function `$redirect`
|
|
8
|
+
# ({Ruact::Server}); neither loads the other.
|
|
9
|
+
module RedirectPath
|
|
10
|
+
# @param path [String] a redirect target already reduced to a path
|
|
11
|
+
# @return [Boolean] whether a browser would read it as another host
|
|
12
|
+
def self.protocol_relative?(path)
|
|
13
|
+
path.match?(%r{\A[/\\]{2}})
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/ruact/routing.rb
CHANGED
|
@@ -24,9 +24,9 @@ module Ruact
|
|
|
24
24
|
# The path segment reuses {Ruact::ServerFunctions::NameBridge} verbatim
|
|
25
25
|
# (D4): `def search_users` → `GET /q/searchUsers`, named
|
|
26
26
|
# `ruact_query_searchUsers`. Invalid or JS-reserved method names raise
|
|
27
|
-
# {Ruact::ConfigurationError} at route-draw time
|
|
28
|
-
# the same method name collide on the route
|
|
29
|
-
#
|
|
27
|
+
# {Ruact::ConfigurationError} at route-draw time, and so do two query
|
|
28
|
+
# classes mounting the same method name (they would collide on the route
|
|
29
|
+
# name and on the export) — both are loud boot failures, never request-time
|
|
30
30
|
# surprises.
|
|
31
31
|
#
|
|
32
32
|
# The generated dispatch controller PRESERVES the query class's namespace
|
|
@@ -56,11 +56,29 @@ module Ruact
|
|
|
56
56
|
|
|
57
57
|
query_class.public_instance_methods(false).each do |query_method|
|
|
58
58
|
js_identifier = ServerFunctions::NameBridge.to_js_identifier(query_method)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
begin
|
|
60
|
+
mapper.get("#{prefix}/#{js_identifier}",
|
|
61
|
+
to: "#{target}##{query_method}",
|
|
62
|
+
as: :"ruact_query_#{js_identifier}")
|
|
63
|
+
rescue ArgumentError => e
|
|
64
|
+
raise unless e.message.include?("already in use")
|
|
65
|
+
|
|
66
|
+
raise_query_name_taken!(query_class, query_method, "#{prefix}/#{js_identifier}")
|
|
67
|
+
end
|
|
62
68
|
end
|
|
63
69
|
end
|
|
70
|
+
|
|
71
|
+
# Rails' own message ("Invalid route name, already in use") names a route
|
|
72
|
+
# the app never wrote. Name the query, the method and the way out.
|
|
73
|
+
def raise_query_name_taken!(query_class, query_method, path)
|
|
74
|
+
raise Ruact::ConfigurationError,
|
|
75
|
+
"#{query_class}##{query_method} cannot be mounted: GET #{path} is already mounted " \
|
|
76
|
+
"in these routes — by another query class that also defines `#{query_method}`, or " \
|
|
77
|
+
"by `ruact_queries #{query_class}` appearing twice. Query names share one namespace " \
|
|
78
|
+
"(one route and one export of @/.ruact/server-functions per name): remove the " \
|
|
79
|
+
"duplicate mount, or rename one of the methods (the scaffold names its search " \
|
|
80
|
+
"after the resource: search_posts, search_comments)."
|
|
81
|
+
end
|
|
64
82
|
end
|
|
65
83
|
end
|
|
66
84
|
end
|
data/lib/ruact/server.rb
CHANGED
|
@@ -14,6 +14,7 @@ require_relative "../ruact"
|
|
|
14
14
|
require_relative "server_functions/error_rendering"
|
|
15
15
|
require_relative "server_functions/bucket_two_payload"
|
|
16
16
|
require_relative "server_functions/name_bridge"
|
|
17
|
+
require_relative "redirect_path"
|
|
17
18
|
require_relative "validation_errors_collector"
|
|
18
19
|
|
|
19
20
|
module Ruact
|
|
@@ -327,6 +328,11 @@ module Ruact
|
|
|
327
328
|
path = uri.path.nil? || uri.path.empty? ? "/" : uri.path
|
|
328
329
|
path += "?#{uri.query}" if uri.query
|
|
329
330
|
path += "##{uri.fragment}" if uri.fragment
|
|
331
|
+
# `https://app//evil.com` is same-origin, and Rails lets it through; as a
|
|
332
|
+
# path, `//evil.com` is a protocol-relative URL to ANOTHER host. Keep
|
|
333
|
+
# the absolute URL for any path a browser would read that way.
|
|
334
|
+
return url if Ruact::RedirectPath.protocol_relative?(path)
|
|
335
|
+
|
|
330
336
|
path
|
|
331
337
|
rescue ::URI::InvalidURIError
|
|
332
338
|
url
|
|
@@ -26,10 +26,14 @@ module Ruact
|
|
|
26
26
|
ELEMENT_HEAD = "$"
|
|
27
27
|
# A client-component element's type is "$L" followed by the import's hex id.
|
|
28
28
|
CLIENT_REF = /\A\$L(?<hex>\h+)\z/
|
|
29
|
+
# A string of 1024+ bytes travels as a `T` row, and the props hold a
|
|
30
|
+
# `"$T<hex>"` reference to it (Story 17-0d).
|
|
31
|
+
TEXT_REF = /\A\$T(?<hex>\h+)\z/
|
|
29
32
|
|
|
30
33
|
# @param wire [String] the raw Flight wire byte string.
|
|
31
34
|
def initialize(wire)
|
|
32
35
|
@rows = FlightWireParser.parse(wire)
|
|
36
|
+
@texts = index_texts(@rows)
|
|
33
37
|
@imports = index_imports(@rows)
|
|
34
38
|
@instances = collect_instances(@rows)
|
|
35
39
|
end
|
|
@@ -74,6 +78,24 @@ module Ruact
|
|
|
74
78
|
end
|
|
75
79
|
end
|
|
76
80
|
|
|
81
|
+
def index_texts(rows)
|
|
82
|
+
rows.each_with_object({}) do |row, acc|
|
|
83
|
+
acc[row[:id]] = row[:payload].dup.force_encoding(Encoding::UTF_8) if row[:class] == :text
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Props as the component receives them: text references resolved.
|
|
88
|
+
def resolve_texts(node)
|
|
89
|
+
case node
|
|
90
|
+
when String
|
|
91
|
+
ref = node.match(TEXT_REF)
|
|
92
|
+
ref && @texts.key?(ref[:hex].to_i(16)) ? @texts[ref[:hex].to_i(16)] : node
|
|
93
|
+
when Array then node.map { |child| resolve_texts(child) }
|
|
94
|
+
when Hash then node.transform_values { |child| resolve_texts(child) }
|
|
95
|
+
else node
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
77
99
|
def import_ids_for(name)
|
|
78
100
|
@imports.select do |_id, meta|
|
|
79
101
|
meta[:export_name] == name || meta[:basename] == name
|
|
@@ -95,7 +117,7 @@ module Ruact
|
|
|
95
117
|
def walk(node, instances)
|
|
96
118
|
if element?(node)
|
|
97
119
|
ref = node[1].match(CLIENT_REF)
|
|
98
|
-
instances << { ref_id: ref[:hex].to_i(16), props: node[3] } if ref
|
|
120
|
+
instances << { ref_id: ref[:hex].to_i(16), props: resolve_texts(node[3]) } if ref
|
|
99
121
|
# An element's props/children may themselves contain nested elements.
|
|
100
122
|
walk(node[3], instances)
|
|
101
123
|
elsif node.is_a?(Array)
|
|
@@ -133,7 +133,12 @@ module Ruact
|
|
|
133
133
|
"This response did not render a ruact component (no Flight data was inlined)."
|
|
134
134
|
end
|
|
135
135
|
|
|
136
|
-
|
|
136
|
+
literal = match[:literal]
|
|
137
|
+
# The literal is JSON (ruact >= 0.0.15). The Ruby-literal decoder stays
|
|
138
|
+
# for documents captured from earlier versions.
|
|
139
|
+
JSON.parse(literal)
|
|
140
|
+
rescue JSON::ParserError
|
|
141
|
+
decode_ruby_string_literal(literal)
|
|
137
142
|
end
|
|
138
143
|
|
|
139
144
|
def json_body?(body)
|
data/lib/ruact/version.rb
CHANGED
data/lib/ruact/view_helper.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "erb"
|
|
3
4
|
require "json"
|
|
4
5
|
require "socket"
|
|
5
6
|
|
|
@@ -214,15 +215,31 @@ module Ruact
|
|
|
214
215
|
|
|
215
216
|
# The `__FLIGHT_DATA` inline bootstrap `<script>` — pushes the per-render
|
|
216
217
|
# Flight payload onto the global queue the bootstrap entry drains on boot.
|
|
217
|
-
#
|
|
218
|
-
#
|
|
218
|
+
#
|
|
219
|
+
# The payload carries application data (props, record content), so it is
|
|
220
|
+
# embedded as a JSON string literal with `<`, `>`, `&`, U+2028 and U+2029
|
|
221
|
+
# escaped as `\uXXXX`. No byte of the payload can then close the `<script>`
|
|
222
|
+
# — `</SCRIPT>`, `</script >` and `<!--` included — and every character
|
|
223
|
+
# reaches JavaScript intact (a Ruby `String#inspect` literal did neither:
|
|
224
|
+
# it escaped only a lowercase `</script>`, and its `\e`-style escapes mean
|
|
225
|
+
# something else in JS).
|
|
226
|
+
SCRIPT_UNSAFE = /[<>&\u2028\u2029]/
|
|
227
|
+
private_constant :SCRIPT_UNSAFE
|
|
228
|
+
|
|
229
|
+
# A JSON string literal that is also safe inside a `<script>` element.
|
|
230
|
+
# The payload is UTF-8 by construction (the serializer normalizes every
|
|
231
|
+
# string); a binary-tagged buffer is retagged, not transcoded.
|
|
232
|
+
def __ruact_script_safe_json(string)
|
|
233
|
+
utf8 = string.encoding == Encoding::UTF_8 ? string : string.dup.force_encoding(Encoding::UTF_8)
|
|
234
|
+
JSON.generate(utf8).gsub(SCRIPT_UNSAFE) { |char| format("\\u%04x", char.ord) }
|
|
235
|
+
end
|
|
236
|
+
|
|
219
237
|
def ruact_flight_data_script(flight_payload)
|
|
220
|
-
escaped_payload = flight_payload.gsub("</script>", '<\/script>')
|
|
221
238
|
<<~HTML.strip
|
|
222
239
|
<script>
|
|
223
240
|
(function() {
|
|
224
241
|
var d = (self.__FLIGHT_DATA = self.__FLIGHT_DATA || []);
|
|
225
|
-
d.push(#{
|
|
242
|
+
d.push(#{__ruact_script_safe_json(flight_payload)});
|
|
226
243
|
})();
|
|
227
244
|
</script>
|
|
228
245
|
HTML
|
|
@@ -257,10 +274,31 @@ module Ruact
|
|
|
257
274
|
# virtual entry id (the manifest key Vite emits for the virtual input).
|
|
258
275
|
entry = vite_manifest_entry(Ruact.bootstrap_virtual_id)
|
|
259
276
|
src = entry ? "/assets/#{entry['file']}" : "/assets/application.js"
|
|
260
|
-
%(<script type="module" src="#{src}"></script>)
|
|
277
|
+
tag = %(<script type="module" src="#{src}"></script>)
|
|
278
|
+
# Story 17-0k — in development with neither the Vite dev server nor a
|
|
279
|
+
# build, that script 404s and the page stays empty with no error.
|
|
280
|
+
Rails.env.development? && entry.nil? ? "#{ruact_vite_down_warning}\n#{tag}" : tag
|
|
261
281
|
end
|
|
262
282
|
end
|
|
263
283
|
|
|
284
|
+
# Story 17-0k — `bin/rails server` on its own, once Vite has run before:
|
|
285
|
+
# the component manifest is on disk, so the render succeeds, but the page's
|
|
286
|
+
# JavaScript is not served and `#root` stays empty — a blank 200 whose only
|
|
287
|
+
# clue was a 404 in the network tab. Development only. Plain HTML with
|
|
288
|
+
# inline style: the warning cannot depend on the bundle that did not load.
|
|
289
|
+
def ruact_vite_down_warning
|
|
290
|
+
server = Ruact.config.vite_dev_server.chomp("/")
|
|
291
|
+
message = "The Vite dev server is not running (#{server}), so this page has no JavaScript " \
|
|
292
|
+
"and its React components cannot render. Start the app with bin/dev, which runs " \
|
|
293
|
+
"Rails and Vite together."
|
|
294
|
+
Rails.logger&.warn("[ruact] #{message}")
|
|
295
|
+
<<~HTML.strip
|
|
296
|
+
<div role="alert" data-ruact-vite-down style="position:fixed;inset:0 0 auto 0;z-index:2147483647;padding:12px 16px;background:#7f1d1d;color:#fff;font:14px/1.4 system-ui,sans-serif">
|
|
297
|
+
<strong>[ruact]</strong> #{ERB::Util.html_escape(message)}
|
|
298
|
+
</div>
|
|
299
|
+
HTML
|
|
300
|
+
end
|
|
301
|
+
|
|
264
302
|
# The Vite dev-server URL for the virtual bootstrap. Vite serves virtual
|
|
265
303
|
# modules under `/@id/`, encoding the resolved id's leading NUL (`\0`) as
|
|
266
304
|
# `__x00__` — so `\0virtual:ruact/bootstrap` is served at
|
|
@@ -270,6 +270,16 @@ let inflightQueries = new WeakMap();
|
|
|
270
270
|
// is still invoked and the error surfaces (Story 9.5 behavior), instead of an
|
|
271
271
|
// invalid call wrongly joining an in-flight no-param request. `null`/no params
|
|
272
272
|
// both serialize to "".
|
|
273
|
+
// Story 17-0c — a query param as it goes on the wire, or `undefined` when it
|
|
274
|
+
// cannot be one. Pages now hand components a `BigInt` (an id beyond JS's safe
|
|
275
|
+
// range) and a `Date` (a Ruby Time); both must be able to go back as params.
|
|
276
|
+
function wireParam(value) {
|
|
277
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
|
278
|
+
if (typeof value === "bigint") return value.toString();
|
|
279
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
|
|
273
283
|
function canonicalParamsKey(params) {
|
|
274
284
|
if (params == null) return "";
|
|
275
285
|
if (typeof params !== "object" || Array.isArray(params)) return null;
|
|
@@ -279,8 +289,8 @@ function canonicalParamsKey(params) {
|
|
|
279
289
|
if (value === undefined) continue;
|
|
280
290
|
if (value === null) {
|
|
281
291
|
tokens.push(encodeURIComponent(key)); // bare key — mirrors buildQueryUrl
|
|
282
|
-
} else if (
|
|
283
|
-
tokens.push(`${encodeURIComponent(key)}=${encodeURIComponent(
|
|
292
|
+
} else if (wireParam(value) !== undefined) {
|
|
293
|
+
tokens.push(`${encodeURIComponent(key)}=${encodeURIComponent(wireParam(value))}`);
|
|
284
294
|
} else {
|
|
285
295
|
return null; // array/object value — buildQueryUrl throws; non-shareable
|
|
286
296
|
}
|
|
@@ -536,16 +546,12 @@ function buildQueryUrl(path, params) {
|
|
|
536
546
|
if (value === undefined) continue;
|
|
537
547
|
if (value === null) {
|
|
538
548
|
bareKeys.push(encodeURIComponent(key));
|
|
539
|
-
} else if (
|
|
540
|
-
|
|
541
|
-
typeof value === "number" ||
|
|
542
|
-
typeof value === "boolean"
|
|
543
|
-
) {
|
|
544
|
-
search.append(key, String(value));
|
|
549
|
+
} else if (wireParam(value) !== undefined) {
|
|
550
|
+
search.append(key, wireParam(value));
|
|
545
551
|
} else {
|
|
546
552
|
throw new TypeError(
|
|
547
553
|
`ruact useQuery for ${path}: param "${key}" must be a string, number, ` +
|
|
548
|
-
"boolean, or null — arrays and objects are rejected",
|
|
554
|
+
"bigint, Date, boolean, or null — arrays and objects are rejected",
|
|
549
555
|
);
|
|
550
556
|
}
|
|
551
557
|
}
|
|
@@ -684,7 +690,10 @@ function buildFetchInit(args, method = "POST") {
|
|
|
684
690
|
body = args;
|
|
685
691
|
} else {
|
|
686
692
|
headers["Content-Type"] = "application/json";
|
|
687
|
-
|
|
693
|
+
// A BigInt (an id beyond JS's safe range, Story 17-0c) has no JSON form:
|
|
694
|
+
// it goes as its decimal string, which Rails reads as it reads any id.
|
|
695
|
+
// A Date already serializes to its ISO string.
|
|
696
|
+
body = JSON.stringify(args ?? {}, (_key, value) => (typeof value === "bigint" ? value.toString() : value));
|
|
688
697
|
}
|
|
689
698
|
|
|
690
699
|
return {
|
|
@@ -491,6 +491,13 @@ describe("mutation core — configureRuactRuntime", () => {
|
|
|
491
491
|
});
|
|
492
492
|
|
|
493
493
|
describe("Story 8.1 — __internals (test-only surface)", () => {
|
|
494
|
+
// Story 17-0c — a BigInt id from a page has no JSON form; it goes as its
|
|
495
|
+
// decimal string instead of throwing.
|
|
496
|
+
it("sends a BigInt argument as its decimal string", () => {
|
|
497
|
+
const init = __internals.buildFetchInit({ id: 9007199254740993n, at: new Date("2026-09-08T00:00:00.000Z") });
|
|
498
|
+
expect(JSON.parse(init.body)).toEqual({ id: "9007199254740993", at: "2026-09-08T00:00:00.000Z" });
|
|
499
|
+
});
|
|
500
|
+
|
|
494
501
|
it("exposes buildFetchInit, resolveCsrfToken, parseResponse for granular asserts", () => {
|
|
495
502
|
expect(typeof __internals.buildFetchInit).toBe("function");
|
|
496
503
|
expect(typeof __internals.resolveCsrfToken).toBe("function");
|
|
@@ -203,6 +203,18 @@ describe("Story 9.5 — _makeQuery / buildQueryUrl wire format (FR88)", () => {
|
|
|
203
203
|
expect(() => buildQueryUrl("/q/search", { q: { deep: 1 } })).toThrow(/arrays and objects are rejected/);
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
+
// Story 17-0c — pages now hand components BigInt ids and Date values; they
|
|
207
|
+
// must be able to go back as query params.
|
|
208
|
+
it("sends a BigInt as its decimal string and a Date as its ISO string", () => {
|
|
209
|
+
expect(buildQueryUrl("/q/search", { id: 9007199254740993n })).toBe("/q/search?id=9007199254740993");
|
|
210
|
+
expect(buildQueryUrl("/q/search", { at: new Date("2026-09-08T12:30:45.123Z") }))
|
|
211
|
+
.toBe("/q/search?at=2026-09-08T12%3A30%3A45.123Z");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("rejects an Invalid Date rather than sending 'Invalid Date'", () => {
|
|
215
|
+
expect(() => buildQueryUrl("/q/search", { at: new Date("nope") })).toThrow(/arrays and objects are rejected/);
|
|
216
|
+
});
|
|
217
|
+
|
|
206
218
|
it("rejects a top-level array of params", () => {
|
|
207
219
|
expect(() => buildQueryUrl("/q/search", [1, 2])).toThrow(/plain object/);
|
|
208
220
|
});
|