ruact 0.0.14 → 0.0.16
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 +2 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +2 -2
- data/lib/ruact/component_attributes.rb +91 -0
- data/lib/ruact/configuration.rb +28 -0
- 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/erb_preprocessor.rb +1 -47
- 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/server.rb +17 -7
- data/lib/ruact/server_functions/action_assigns.rb +52 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +1 -1
- 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/lib/ruact.rb +1 -0
- 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 +178 -0
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +84 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +199 -9
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +19 -23
- metadata +8 -2
|
@@ -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/server.rb
CHANGED
|
@@ -14,6 +14,8 @@ 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 "server_functions/action_assigns"
|
|
18
|
+
require_relative "redirect_path"
|
|
17
19
|
require_relative "validation_errors_collector"
|
|
18
20
|
|
|
19
21
|
module Ruact
|
|
@@ -68,6 +70,9 @@ module Ruact
|
|
|
68
70
|
module Server
|
|
69
71
|
extend ActiveSupport::Concern
|
|
70
72
|
|
|
73
|
+
# A function call returns the action's own ivars, not its callbacks'.
|
|
74
|
+
include Ruact::ServerFunctions::ActionAssigns
|
|
75
|
+
|
|
71
76
|
include Ruact::ServerFunctions::ErrorRendering
|
|
72
77
|
include Ruact::ValidationErrorsCollector
|
|
73
78
|
|
|
@@ -201,16 +206,16 @@ module Ruact
|
|
|
201
206
|
# Story 9.2 AC2/AC4 (D1) — Bucket-2 success-path negotiation. When the
|
|
202
207
|
# action finished without an explicit render on a function-call request
|
|
203
208
|
# ({#__ruact_function_call?} — `Accept: application/json`, non-GET), serialize
|
|
204
|
-
# the
|
|
205
|
-
#
|
|
209
|
+
# the instance variables the ACTION assigned (Rails `view_assigns` minus
|
|
210
|
+
# what callbacks set and the action left alone — see
|
|
211
|
+
# {Ruact::ServerFunctions::ActionAssigns}) as a JSON object keyed by ivar name, or
|
|
206
212
|
# `204 No Content` when none were set. Any other request shape falls through
|
|
207
213
|
# to `super` so Bucket-1 rendering — the host's `Ruact::Controller` Flight
|
|
208
214
|
# re-render, then Rails — is byte-for-byte unchanged (AC1).
|
|
209
215
|
#
|
|
210
|
-
#
|
|
211
|
-
#
|
|
212
|
-
#
|
|
213
|
-
# exactly what the action assigned. Each value is serialized through the
|
|
216
|
+
# Rails' `view_assigns` already excludes its protected `@_`-prefixed
|
|
217
|
+
# internals (including the CSRF `@_marked_for_same_origin_verification`
|
|
218
|
+
# flag); ActionAssigns then drops what callbacks set. Each value is serialized through the
|
|
214
219
|
# `ruact_props` / `Ruact::Serializable` / `strict_serialization` rules
|
|
215
220
|
# ({Ruact::ServerFunctions::BucketTwoPayload}); a single ivar stays keyed
|
|
216
221
|
# (no magic unwrap).
|
|
@@ -221,7 +226,7 @@ module Ruact
|
|
|
221
226
|
# dev `@errors`) are dropped from the serialized assigns; Rails' own
|
|
222
227
|
# `view_assigns` only filters a fixed set of `@_` ivars, not every `@__`
|
|
223
228
|
# one (see {Ruact::ValidationErrorsCollector::RESERVED_ASSIGN_KEYS}).
|
|
224
|
-
assigns = view_assigns.except(*ValidationErrorsCollector::RESERVED_ASSIGN_KEYS)
|
|
229
|
+
assigns = __ruact_action_assigns(view_assigns.except(*ValidationErrorsCollector::RESERVED_ASSIGN_KEYS))
|
|
225
230
|
|
|
226
231
|
# Story 15.0 (F6) — reaching this branch means ruact itself is producing
|
|
227
232
|
# the function-call response (fall-through: injection, plain JSON, or
|
|
@@ -327,6 +332,11 @@ module Ruact
|
|
|
327
332
|
path = uri.path.nil? || uri.path.empty? ? "/" : uri.path
|
|
328
333
|
path += "?#{uri.query}" if uri.query
|
|
329
334
|
path += "##{uri.fragment}" if uri.fragment
|
|
335
|
+
# `https://app//evil.com` is same-origin, and Rails lets it through; as a
|
|
336
|
+
# path, `//evil.com` is a protocol-relative URL to ANOTHER host. Keep
|
|
337
|
+
# the absolute URL for any path a browser would read that way.
|
|
338
|
+
return url if Ruact::RedirectPath.protocol_relative?(path)
|
|
339
|
+
|
|
330
340
|
path
|
|
331
341
|
rescue ::URI::InvalidURIError
|
|
332
342
|
url
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module ServerFunctions
|
|
5
|
+
# What a server function's JSON is made of (decision, 2026-10-03): the
|
|
6
|
+
# ivars the ACTION assigned, not everything a view would see.
|
|
7
|
+
#
|
|
8
|
+
# Two filters. Callbacks run before `send_action`, so what they put on the
|
|
9
|
+
# controller (a `set_post`, a `set_current_user`) is recorded there and
|
|
10
|
+
# left out of the JSON unless the action assigns it again. And some ivars
|
|
11
|
+
# are filled DURING the action without the action naming them — Devise's
|
|
12
|
+
# `current_user` memoizes `@current_user` on first call, Pundit's
|
|
13
|
+
# `authorize` sets `@pundit` — so names in
|
|
14
|
+
# `Ruact.config.server_function_hidden_ivars`, and every name starting
|
|
15
|
+
# with `_`, are never returned. Without these, a function call carried the
|
|
16
|
+
# signed-in user, and under `strict_serialization` (production's default)
|
|
17
|
+
# a user model without `ruact_props` made every call a 500. A page
|
|
18
|
+
# render's view still sees every ivar; only the function-call JSON
|
|
19
|
+
# narrows.
|
|
20
|
+
module ActionAssigns
|
|
21
|
+
# The snapshot's own name, kept out of what it filters.
|
|
22
|
+
CALLBACK_IVARS_KEY = "__ruact_callback_ivars"
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def send_action(...)
|
|
27
|
+
if __ruact_function_call?
|
|
28
|
+
@__ruact_callback_ivars = instance_variables.to_h do |name|
|
|
29
|
+
[name.to_s.delete_prefix("@"), instance_variable_get(name)]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# +assigns+ minus what callbacks set and the action left holding the
|
|
36
|
+
# same object. Only a DIFFERENT object makes it the action's: setting
|
|
37
|
+
# the same value again (`@ok = false` after a callback did, a memoized
|
|
38
|
+
# helper returning what the callback already stored) leaves it out.
|
|
39
|
+
# Security first — a callback's `@current_user_id = 5` must not ride
|
|
40
|
+
# every response — at the price of a key that is absent when callback
|
|
41
|
+
# and action happen to agree.
|
|
42
|
+
def __ruact_action_assigns(assigns)
|
|
43
|
+
before = @__ruact_callback_ivars || {}
|
|
44
|
+
hidden = Ruact.config.server_function_hidden_ivars
|
|
45
|
+
assigns.reject do |name, value|
|
|
46
|
+
name == CALLBACK_IVARS_KEY || name.start_with?("_") || hidden.include?(name) ||
|
|
47
|
+
(before.key?(name) && before[name].equal?(value))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -7,7 +7,7 @@ require_relative "../errors"
|
|
|
7
7
|
module Ruact
|
|
8
8
|
module ServerFunctions
|
|
9
9
|
# Story 9.2 — pure serializer for the Bucket-2 (imperative `await fn()`)
|
|
10
|
-
# response body. Takes the
|
|
10
|
+
# response body. Takes the instance variables the action returns (Rails
|
|
11
11
|
# `view_assigns`, resolved by the caller) and produces a JSON-ready Ruby
|
|
12
12
|
# Hash, keyed by ivar name, applying the SAME prop-exposure policy as the
|
|
13
13
|
# Flight serializer ({Ruact::Flight::Serializer#serialize_unknown}):
|
|
@@ -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
|
data/lib/ruact.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative "ruact/flight"
|
|
|
9
9
|
require_relative "ruact/string_distance"
|
|
10
10
|
require_relative "ruact/layout_source"
|
|
11
11
|
require_relative "ruact/component_contract"
|
|
12
|
+
require_relative "ruact/component_attributes"
|
|
12
13
|
require_relative "ruact/erb_preprocessor"
|
|
13
14
|
require_relative "ruact/render_context"
|
|
14
15
|
require_relative "ruact/html_converter"
|
|
@@ -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
|
});
|
|
@@ -31,11 +31,13 @@
|
|
|
31
31
|
// against shuffling being turned on later.
|
|
32
32
|
|
|
33
33
|
import { describe, it, expect, beforeEach } from "vitest";
|
|
34
|
+
import { Fragment } from "react";
|
|
34
35
|
import fs from "node:fs";
|
|
35
36
|
import path from "node:path";
|
|
36
37
|
import {
|
|
37
38
|
buildTree,
|
|
38
39
|
createFromFlightPayload,
|
|
40
|
+
createRowParser,
|
|
39
41
|
clearPendingChunks,
|
|
40
42
|
} from "./runtime/flight-client.js";
|
|
41
43
|
|
|
@@ -319,3 +321,179 @@ describe("Story 17.0a — the contract, driven by a gem-produced fixture", () =>
|
|
|
319
321
|
expect(tree.props.posts).toEqual([{ id: 1, title: "$5 plan" }]);
|
|
320
322
|
});
|
|
321
323
|
});
|
|
324
|
+
|
|
325
|
+
// Story 17-0d — every string of 1024+ bytes travels as a `T` row framed by its
|
|
326
|
+
// BYTE length, with no trailing newline. The decoder split on "\n" and so lost
|
|
327
|
+
// the root row: one long post body blanked the whole page. The fixtures are
|
|
328
|
+
// what the Ruby serializer emits (spec/ruact/flight/text_framing_fixtures_spec.rb
|
|
329
|
+
// writes and guards them).
|
|
330
|
+
describe("text rows (Story 17-0d)", () => {
|
|
331
|
+
const FIXTURES = path.join(import.meta.dirname, "../../../spec/fixtures/flight");
|
|
332
|
+
const read = (name) => fs.readFileSync(path.join(FIXTURES, name));
|
|
333
|
+
const expected = JSON.parse(read("text_framing_expected.json").toString("utf8"));
|
|
334
|
+
|
|
335
|
+
const rowsOf = (chunks) => {
|
|
336
|
+
const rows = new Map();
|
|
337
|
+
const parser = createRowParser(({ id, row }) => rows.set(id, row));
|
|
338
|
+
for (const chunk of chunks) parser.push(chunk);
|
|
339
|
+
parser.end();
|
|
340
|
+
return rows;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
it("decodes every value the serializer framed, byte for byte", () => {
|
|
344
|
+
const tree = createFromFlightPayload(read("text_framing.txt").toString("utf8"), MODULE_REGISTRY);
|
|
345
|
+
expect(tree).toEqual(expected);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("puts long text inside Suspense content where the deferred row points", () => {
|
|
349
|
+
const tree = createFromFlightPayload(read("text_framing_suspense.txt").toString("utf8"), MODULE_REGISTRY);
|
|
350
|
+
const deferred = tree.props.children.type; // the already-arrived deferred row, wrapped
|
|
351
|
+
expect(deferred().props.children).toBe(expected.multibyte);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("does not depend on where the network splits the bytes", () => {
|
|
355
|
+
const bytes = new Uint8Array(read("text_framing.txt"));
|
|
356
|
+
const whole = rowsOf([bytes]);
|
|
357
|
+
|
|
358
|
+
// Every single split point: inside ids, `T` lengths, text bodies and
|
|
359
|
+
// multibyte code points, and between rows.
|
|
360
|
+
for (let cut = 1; cut < bytes.length; cut += 1) {
|
|
361
|
+
const split = rowsOf([bytes.subarray(0, cut), bytes.subarray(cut)]);
|
|
362
|
+
expect(split).toEqual(whole);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// And one byte at a time.
|
|
366
|
+
expect(rowsOf([...bytes].map((b) => Uint8Array.of(b)))).toEqual(whole);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("refuses a truncated text row instead of rendering part of the page", () => {
|
|
370
|
+
const bytes = new Uint8Array(read("text_framing.txt"));
|
|
371
|
+
const firstText = Buffer.from(bytes).indexOf(":T") + 10;
|
|
372
|
+
expect(() => rowsOf([bytes.subarray(0, firstText)])).toThrow(/Truncated Flight payload/);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("refuses a malformed text length", () => {
|
|
376
|
+
const enc = new TextEncoder();
|
|
377
|
+
expect(() => rowsOf([enc.encode("1:Tzz,abc0:\"$T1\"\n")])).toThrow(/Malformed text row length/);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("names a text reference whose row never arrived", () => {
|
|
381
|
+
expect(() => createFromFlightPayload('0:{"body":"$T7"}\n', MODULE_REGISTRY))
|
|
382
|
+
.toThrow(/Text row 7 is referenced but was never received/);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("skips a stray line without a colon instead of losing the row after it", () => {
|
|
386
|
+
const rows = rowsOf([new TextEncoder().encode('garbage\n0:{"a":1}\n')]);
|
|
387
|
+
expect(rows.get(0)).toEqual({ kind: "model", value: { a: 1 } });
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("reads a large row arriving in small chunks in linear time", () => {
|
|
391
|
+
const big = JSON.stringify({ items: Array.from({ length: 60000 }, (_, i) => ({ id: i, name: `item ${i}` })) });
|
|
392
|
+
const bytes = new TextEncoder().encode(`0:${big}\n`);
|
|
393
|
+
const chunks = [];
|
|
394
|
+
for (let i = 0; i < bytes.length; i += 4096) chunks.push(bytes.subarray(i, i + 4096));
|
|
395
|
+
const started = performance.now();
|
|
396
|
+
const rows = rowsOf(chunks);
|
|
397
|
+
expect(rows.get(0).value.items).toHaveLength(60000);
|
|
398
|
+
// ~2.3 MB in 4 KB chunks; quadratic re-copying took seconds.
|
|
399
|
+
expect(performance.now() - started).toBeLessThan(1000);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("still reads a last row that lost its trailing newline", () => {
|
|
403
|
+
const rows = rowsOf([new TextEncoder().encode('0:{"a":1}')]);
|
|
404
|
+
expect(rows.get(0)).toEqual({ kind: "model", value: { a: 1 } });
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// Story 17-0c — the serializer emits Ruby Time/DateTime as "$D<ISO>" and
|
|
409
|
+
// integers beyond ±(2^53 − 1) as "$n<decimal>". Components got the marker
|
|
410
|
+
// strings. The fixture is the Ruby serializer's own output
|
|
411
|
+
// (spec/ruact/flight/scalar_fixtures_spec.rb writes and guards it).
|
|
412
|
+
describe("dates and big integers (Story 17-0c)", () => {
|
|
413
|
+
const FIXTURES = path.join(import.meta.dirname, "../../../spec/fixtures/flight");
|
|
414
|
+
const payload = fs.readFileSync(path.join(FIXTURES, "scalar_round_trip.txt"), "utf8");
|
|
415
|
+
const expected = JSON.parse(fs.readFileSync(path.join(FIXTURES, "scalar_round_trip_expected.json"), "utf8"));
|
|
416
|
+
|
|
417
|
+
// Expected JSON tags what JSON cannot hold: { date: ISO } and { bigint: "…" }.
|
|
418
|
+
const revive = (value) => {
|
|
419
|
+
if (Array.isArray(value)) return value.map(revive);
|
|
420
|
+
if (value && typeof value === "object") {
|
|
421
|
+
if ("date" in value) return new Date(value.date);
|
|
422
|
+
if ("bigint" in value) return BigInt(value.bigint);
|
|
423
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, revive(v)]));
|
|
424
|
+
}
|
|
425
|
+
return value;
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
it("rebuilds every value with its JS type, nested ones included", () => {
|
|
429
|
+
const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
|
|
430
|
+
expect(tree).toEqual(revive(expected));
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("gives a Date the same instant, offset applied, to the millisecond", () => {
|
|
434
|
+
const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
|
|
435
|
+
expect(tree.time).toBeInstanceOf(Date);
|
|
436
|
+
expect(tree.time.toISOString()).toBe("2026-09-08T12:30:45.123Z");
|
|
437
|
+
expect(tree.zoned.toISOString()).toBe("2026-09-08T12:30:00.000Z");
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("keeps safe integers as numbers and only the unsafe ones as BigInt", () => {
|
|
441
|
+
const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
|
|
442
|
+
expect(typeof tree.max_safe).toBe("number");
|
|
443
|
+
expect(typeof tree.min_safe).toBe("number");
|
|
444
|
+
expect(typeof tree.big).toBe("bigint");
|
|
445
|
+
expect(tree.big).toBe(9007199254740993n);
|
|
446
|
+
expect(tree.negative_big).toBe(-18446744073709551616n);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("leaves a literal string that starts with $D a string", () => {
|
|
450
|
+
const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
|
|
451
|
+
expect(tree.literal).toBe("$D2026-01-01 is a string");
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// A server-rendered tree has unkeyed siblings (an <h1> beside a component, the
|
|
456
|
+
// rows of an ERB loop). Handed to React as an array they made it log "Each
|
|
457
|
+
// child in a list should have a unique key" on the Getting Started page. They
|
|
458
|
+
// are passed as separate children instead — static, like JSX — and no key is
|
|
459
|
+
// invented (an invented index key can collide with a template's own key and
|
|
460
|
+
// duplicate a row, or reset state when a sibling comes or goes).
|
|
461
|
+
describe("sibling children", () => {
|
|
462
|
+
const payload = [
|
|
463
|
+
'1:I["/TaskList.jsx","TaskList"]',
|
|
464
|
+
'0:[["$","h1",null,{"children":"Hello"}],["$","$L1",null,{"tasks":[]}],["$","ul",null,{"children":[["$","li",null,{"children":"a"}],["$","li","explicit",{"children":"b"}]]}]]',
|
|
465
|
+
"",
|
|
466
|
+
].join("\n");
|
|
467
|
+
|
|
468
|
+
it("wraps several root siblings in a Fragment, without inventing keys", () => {
|
|
469
|
+
const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
|
|
470
|
+
expect(tree.type).toBe(Fragment);
|
|
471
|
+
expect(tree.props.children.map((el) => el.key)).toEqual([null, null, null]);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("keeps the template's own key and invents none for the others", () => {
|
|
475
|
+
const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
|
|
476
|
+
const ul = tree.props.children[2];
|
|
477
|
+
expect(ul.props.children.map((el) => el.key)).toEqual([null, "explicit"]);
|
|
478
|
+
expect(ul.props.children).toHaveLength(2);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("wraps a Suspense boundary's deferred siblings the same way", () => {
|
|
482
|
+
const deferred = buildTree([["$", "h2", null, { children: "A" }], ["$", "p", null, { children: "B" }]], EMPTY_ROWS, MODULE_REGISTRY);
|
|
483
|
+
expect(deferred.type).toBe(Fragment);
|
|
484
|
+
expect(deferred.props.children).toHaveLength(2);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("does not spread a data array passed as children (it stays mutable)", () => {
|
|
488
|
+
const tree = createFromFlightPayload(payloadWithProps({ children: ["a", "b", "c"] }), MODULE_REGISTRY);
|
|
489
|
+
expect(tree.props.children).toEqual(["a", "b", "c"]);
|
|
490
|
+
expect(Object.isFrozen(tree.props.children)).toBe(false);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("leaves a single root, data arrays and one-element children as they were", () => {
|
|
494
|
+
const tree = createFromFlightPayload(payloadWithProps({ tasks: [{ id: 1 }, { id: 2 }] }), MODULE_REGISTRY);
|
|
495
|
+
expect(tree.key).toBeNull();
|
|
496
|
+
expect(tree.props.tasks).toEqual([{ id: 1 }, { id: 2 }]);
|
|
497
|
+
expect(createFromFlightPayload('0:[1,2]\n', MODULE_REGISTRY)).toEqual([1, 2]);
|
|
498
|
+
});
|
|
499
|
+
});
|