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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -1
  3. data/lib/generators/ruact/install/install_generator.rb +113 -2
  4. data/lib/generators/ruact/install/templates/Procfile.dev.tt +1 -1
  5. data/lib/generators/ruact/install/templates/package.json.tt +4 -4
  6. data/lib/generators/ruact/scaffold/scaffold_generator.rb +39 -12
  7. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +4 -3
  8. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +8 -8
  9. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +8 -8
  10. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +2 -2
  11. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +1 -1
  12. data/lib/ruact/configuration.rb +6 -4
  13. data/lib/ruact/controller/format_negotiation.rb +83 -0
  14. data/lib/ruact/controller/page_rendering.rb +1 -1
  15. data/lib/ruact/controller.rb +7 -0
  16. data/lib/ruact/flight/js_date.rb +22 -0
  17. data/lib/ruact/flight/renderer.rb +8 -3
  18. data/lib/ruact/flight/serializer.rb +10 -4
  19. data/lib/ruact/flight/utf8.rb +36 -0
  20. data/lib/ruact/flight.rb +2 -0
  21. data/lib/ruact/redirect_path.rb +16 -0
  22. data/lib/ruact/routing.rb +24 -6
  23. data/lib/ruact/server.rb +6 -0
  24. data/lib/ruact/testing/component_query.rb +23 -1
  25. data/lib/ruact/testing/flight_extractor.rb +6 -1
  26. data/lib/ruact/version.rb +1 -1
  27. data/lib/ruact/view_helper.rb +43 -5
  28. data/vendor/javascript/ruact-server-functions-runtime/index.js +19 -10
  29. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +7 -0
  30. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +12 -0
  31. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +131 -0
  32. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +84 -0
  33. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +167 -5
  34. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +10 -19
  35. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +1 -1
  36. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +3 -3
  37. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +3 -3
  38. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +4 -4
  39. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +4 -4
  40. 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 every format, HTML first — the page; a `.json` request or a
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)
@@ -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
- import_count_before = @request.completed_import_chunks.length
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 any import rows discovered during deferred serialization
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
- # Unknown symbols: just use the name as a string
93
- value.to_s
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#{value.iso8601(3)}"
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(&:to_s).transform_values { |v| serialize_model(v) }
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
@@ -3,6 +3,8 @@
3
3
  require_relative "flight/react_element"
4
4
  require_relative "flight/request"
5
5
  require_relative "flight/row_emitter"
6
+ require_relative "flight/js_date"
7
+ require_relative "flight/utf8"
6
8
  require_relative "flight/serializer"
7
9
  require_relative "flight/renderer"
8
10
 
@@ -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; two query classes mounting
28
- # the same method name collide on the route NAME / path and fail Rails' own
29
- # duplicate checks — both are loud boot failures, never request-time
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
- mapper.get("#{prefix}/#{js_identifier}",
60
- to: "#{target}##{query_method}",
61
- as: :"ruact_query_#{js_identifier}")
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
- decode_ruby_string_literal(match[:literal])
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruact
4
- VERSION = "0.0.13"
4
+ VERSION = "0.0.15"
5
5
  end
@@ -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
- # `</script>` in the payload is escaped to prevent an HTML/XSS breakout
218
- # (mandatory whenever Flight data is inlined into a `<script>` tag).
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(#{escaped_payload.inspect});
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 (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
283
- tokens.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
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
- typeof value === "string" ||
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
- body = JSON.stringify(args ?? {});
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
  });