ruact 0.0.1
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 +7 -0
- data/.github/workflows/ci.yml +166 -0
- data/.rubocop.yml +89 -0
- data/CHANGELOG.md +32 -0
- data/README.md +35 -0
- data/RELEASING.md +203 -0
- data/Rakefile +10 -0
- data/SECURITY.md +62 -0
- data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +163 -0
- data/lib/generators/ruact/install/install_generator.rb +100 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +51 -0
- data/lib/generators/ruact/install/templates/initializer.rb.tt +18 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +26 -0
- data/lib/ruact/client_manifest.rb +115 -0
- data/lib/ruact/component_registry.rb +31 -0
- data/lib/ruact/configuration.rb +32 -0
- data/lib/ruact/controller.rb +195 -0
- data/lib/ruact/doctor.rb +84 -0
- data/lib/ruact/erb_preprocessor.rb +120 -0
- data/lib/ruact/erb_preprocessor_hook.rb +20 -0
- data/lib/ruact/errors.rb +14 -0
- data/lib/ruact/flight/react_element.rb +40 -0
- data/lib/ruact/flight/renderer.rb +73 -0
- data/lib/ruact/flight/request.rb +54 -0
- data/lib/ruact/flight/row_emitter.rb +37 -0
- data/lib/ruact/flight/serializer.rb +215 -0
- data/lib/ruact/flight.rb +12 -0
- data/lib/ruact/html_converter.rb +159 -0
- data/lib/ruact/railtie.rb +99 -0
- data/lib/ruact/render_pipeline.rb +107 -0
- data/lib/ruact/serializable.rb +58 -0
- data/lib/ruact/version.rb +5 -0
- data/lib/ruact/view_helper.rb +23 -0
- data/lib/ruact.rb +48 -0
- data/lib/rubocop/cop/ruact/no_extend_self.rb +46 -0
- data/lib/rubocop/cop/ruact/no_io_in_flight.rb +72 -0
- data/lib/rubocop/cop/ruact/no_shared_state.rb +49 -0
- data/lib/rubocop/cop/ruact.rb +5 -0
- data/lib/tasks/benchmark.rake +70 -0
- data/lib/tasks/rsc.rake +9 -0
- data/sig/ruact.rbs +4 -0
- data/spec/benchmarks/baseline.json +1 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +92 -0
- data/spec/fixtures/flight/README.md +88 -0
- data/spec/fixtures/flight/array.txt +1 -0
- data/spec/fixtures/flight/as_json_object.txt +2 -0
- data/spec/fixtures/flight/boolean_false.txt +1 -0
- data/spec/fixtures/flight/boolean_true.txt +1 -0
- data/spec/fixtures/flight/client_component_with_props.txt +2 -0
- data/spec/fixtures/flight/client_reference.txt +2 -0
- data/spec/fixtures/flight/hash.txt +1 -0
- data/spec/fixtures/flight/nil.txt +1 -0
- data/spec/fixtures/flight/number_float.txt +1 -0
- data/spec/fixtures/flight/number_integer.txt +1 -0
- data/spec/fixtures/flight/react_element_no_props.txt +1 -0
- data/spec/fixtures/flight/redirect_row.txt +1 -0
- data/spec/fixtures/flight/serializable_object.txt +2 -0
- data/spec/fixtures/flight/string_basic.txt +1 -0
- data/spec/fixtures/flight/string_dollar_escape.txt +1 -0
- data/spec/ruact/client_manifest_spec.rb +126 -0
- data/spec/ruact/controller_spec.rb +213 -0
- data/spec/ruact/doctor_spec.rb +234 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +52 -0
- data/spec/ruact/erb_preprocessor_spec.rb +89 -0
- data/spec/ruact/errors_spec.rb +43 -0
- data/spec/ruact/flight/renderer_spec.rb +122 -0
- data/spec/ruact/flight/serializer_spec.rb +453 -0
- data/spec/ruact/html_converter_spec.rb +147 -0
- data/spec/ruact/install_generator_spec.rb +212 -0
- data/spec/ruact/railtie_spec.rb +156 -0
- data/spec/ruact/render_pipeline_spec.rb +474 -0
- data/spec/ruact/serializable_spec.rb +53 -0
- data/spec/ruact/view_helper_spec.rb +46 -0
- data/spec/spec_helper.rb +16 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +25 -0
- data/spec/support/rails_stub.rb +45 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +163 -0
- metadata +136 -0
data/lib/ruact.rb
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ruact/version"
|
|
4
|
+
require_relative "ruact/errors"
|
|
5
|
+
require_relative "ruact/configuration"
|
|
6
|
+
require_relative "ruact/serializable"
|
|
7
|
+
require_relative "ruact/flight"
|
|
8
|
+
require_relative "ruact/erb_preprocessor"
|
|
9
|
+
require_relative "ruact/component_registry"
|
|
10
|
+
require_relative "ruact/html_converter"
|
|
11
|
+
require_relative "ruact/client_manifest"
|
|
12
|
+
require_relative "ruact/render_pipeline"
|
|
13
|
+
require_relative "ruact/view_helper"
|
|
14
|
+
require_relative "ruact/erb_preprocessor_hook"
|
|
15
|
+
# Railtie loads ruact/controller when inside a Rails app
|
|
16
|
+
require_relative "ruact/railtie" if defined?(Rails)
|
|
17
|
+
|
|
18
|
+
module Ruact
|
|
19
|
+
class << self
|
|
20
|
+
attr_accessor :manifest, :streaming_mode
|
|
21
|
+
|
|
22
|
+
# Returns the absolute path to the Vite plugin bundled inside this gem.
|
|
23
|
+
# Use this in vite.config.js: import ruact from '<%= Ruact.vite_plugin_path %>'
|
|
24
|
+
# Re-run `rails generate ruact:install` after gem upgrades to refresh the path.
|
|
25
|
+
#
|
|
26
|
+
# @return [String] absolute path to vendor/javascript/vite-plugin-ruact/index.js
|
|
27
|
+
def vite_plugin_path
|
|
28
|
+
File.expand_path("../vendor/javascript/vite-plugin-ruact/index.js", __dir__)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Yields the configuration object for block-style setup.
|
|
32
|
+
#
|
|
33
|
+
# @example
|
|
34
|
+
# Ruact.configure do |config|
|
|
35
|
+
# config.strict_serialization = true
|
|
36
|
+
# end
|
|
37
|
+
def configure
|
|
38
|
+
yield config
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns the singleton configuration instance.
|
|
42
|
+
#
|
|
43
|
+
# @return [Ruact::Configuration]
|
|
44
|
+
def config
|
|
45
|
+
@config ||= Configuration.new
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Ruact
|
|
6
|
+
# Prohibits `extend self` and `module_function` in all files.
|
|
7
|
+
# Modules must use explicit class methods or classes with initialize (NFR13).
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# # bad
|
|
11
|
+
# module MyModule
|
|
12
|
+
# extend self
|
|
13
|
+
# def do_thing; end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# # good
|
|
17
|
+
# module MyModule
|
|
18
|
+
# def self.do_thing; end
|
|
19
|
+
# end
|
|
20
|
+
class NoExtendSelf < Base
|
|
21
|
+
MSG = "extend self is prohibited (NFR13). Use explicit class methods or a class with initialize."
|
|
22
|
+
|
|
23
|
+
def on_send(node)
|
|
24
|
+
return unless extend_self?(node) || module_function_bare?(node)
|
|
25
|
+
|
|
26
|
+
add_offense(node)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def extend_self?(node)
|
|
32
|
+
node.method_name == :extend &&
|
|
33
|
+
node.receiver.nil? &&
|
|
34
|
+
node.arguments.one? &&
|
|
35
|
+
node.first_argument.self_type?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def module_function_bare?(node)
|
|
39
|
+
node.method_name == :module_function &&
|
|
40
|
+
node.receiver.nil? &&
|
|
41
|
+
node.arguments.empty?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Ruact
|
|
6
|
+
# Prohibits I/O calls in lib/ruact/flight/** modules.
|
|
7
|
+
# Flight modules must be pure value transformations with no side effects (NFR10).
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# # bad (in lib/ruact/flight/serializer.rb)
|
|
11
|
+
# File.read("manifest.json")
|
|
12
|
+
# Rails.logger.debug("serializing")
|
|
13
|
+
# puts "debug"
|
|
14
|
+
#
|
|
15
|
+
# # good
|
|
16
|
+
# def serialize(value, request:)
|
|
17
|
+
# case value
|
|
18
|
+
# when NilClass then "null"
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
class NoIoInFlight < Base
|
|
22
|
+
MSG = "I/O is not allowed in flight/** modules (NFR10). Move I/O to the imperative shell."
|
|
23
|
+
|
|
24
|
+
IO_METHODS = %i[
|
|
25
|
+
read write open puts print p pp warn
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
IO_RECEIVERS = %w[
|
|
29
|
+
File IO Rails.logger Net Socket TCPSocket UDPSocket
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
# @!method io_send?(node)
|
|
33
|
+
def_node_matcher :io_send?, <<~PATTERN
|
|
34
|
+
(send {nil? (const ...) (send ...)} {#{IO_METHODS.map { |m| ":#{m}" }.join(' ')}} ...)
|
|
35
|
+
PATTERN
|
|
36
|
+
|
|
37
|
+
def on_send(node)
|
|
38
|
+
return unless flight_file?
|
|
39
|
+
|
|
40
|
+
receiver = node.receiver
|
|
41
|
+
method = node.method_name
|
|
42
|
+
|
|
43
|
+
io_via_nil_receiver = IO_METHODS.include?(method) && receiver.nil?
|
|
44
|
+
io_via_logger = IO_METHODS.include?(method) && !receiver.nil? && rails_logger_receiver?(receiver)
|
|
45
|
+
io_via_class = receiver && io_class_receiver?(receiver)
|
|
46
|
+
|
|
47
|
+
add_offense(node) if io_via_nil_receiver || io_via_logger || io_via_class
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def flight_file?
|
|
53
|
+
processed_source.path.to_s.include?("lib/ruact/flight/")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def rails_logger_receiver?(node)
|
|
57
|
+
return false unless node.send_type? || node.const_type?
|
|
58
|
+
|
|
59
|
+
src = node.source
|
|
60
|
+
src == "Rails.logger" || src.start_with?("Rails.logger.")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def io_class_receiver?(node)
|
|
64
|
+
return false unless node.const_type? || node.send_type?
|
|
65
|
+
|
|
66
|
+
src = node.source
|
|
67
|
+
%w[File IO Net Socket TCPSocket UDPSocket].any? { |cls| src == cls || src.start_with?("#{cls}::") }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Ruact
|
|
6
|
+
# Prohibits shared mutable state: class variables, Thread.current, and
|
|
7
|
+
# module-level instance variables. All data must flow via explicit method
|
|
8
|
+
# arguments (NFR8, NFR13).
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# # bad
|
|
12
|
+
# @@manifest = nil
|
|
13
|
+
# Thread.current[:rsc_request] = req
|
|
14
|
+
#
|
|
15
|
+
# # good
|
|
16
|
+
# def serialize(value, request:)
|
|
17
|
+
# request.allocate_id
|
|
18
|
+
# end
|
|
19
|
+
class NoSharedState < Base
|
|
20
|
+
MSG = "Shared state is prohibited (NFR8/NFR13). Pass data as explicit method arguments."
|
|
21
|
+
|
|
22
|
+
def on_cvasgn(node)
|
|
23
|
+
add_offense(node)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def on_send(node)
|
|
27
|
+
return unless thread_current_write?(node)
|
|
28
|
+
|
|
29
|
+
add_offense(node)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def thread_current_write?(node)
|
|
35
|
+
# Thread.current[:key] = value → send(send(const nil :Thread) :current) :[]= ...
|
|
36
|
+
# Thread.current[:key] → send(send(const nil :Thread) :current) :[] ...
|
|
37
|
+
return false unless %i[[]= []].include?(node.method_name)
|
|
38
|
+
|
|
39
|
+
receiver = node.receiver
|
|
40
|
+
return false unless receiver&.send_type?
|
|
41
|
+
return false unless receiver.method_name == :current
|
|
42
|
+
|
|
43
|
+
recv2 = receiver.receiver
|
|
44
|
+
recv2&.const_type? && recv2.short_name == :Thread
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
namespace :benchmark do
|
|
7
|
+
desc "Run speed benchmark with benchmark-ips (development reporting)"
|
|
8
|
+
task :speed do
|
|
9
|
+
require "benchmark/ips"
|
|
10
|
+
require "rails_rsc"
|
|
11
|
+
|
|
12
|
+
manifest = RailsRsc::ClientManifest.from_hash(
|
|
13
|
+
(1..20).to_h do |i|
|
|
14
|
+
["Component#{i}", { "id" => "/assets/Component#{i}.js",
|
|
15
|
+
"name" => "Component#{i}",
|
|
16
|
+
"chunks" => ["/assets/Component#{i}.js"] }]
|
|
17
|
+
end
|
|
18
|
+
)
|
|
19
|
+
pipeline = RailsRsc::RenderPipeline.new(manifest)
|
|
20
|
+
erb_typical = "<div>\n#{(1..20).map { |i| "<Component#{i} index={#{i}} />" }.join("\n")}\n</div>"
|
|
21
|
+
|
|
22
|
+
ctx = Object.new
|
|
23
|
+
binding_ctx = ctx.instance_eval { binding }
|
|
24
|
+
|
|
25
|
+
Benchmark.ips do |x|
|
|
26
|
+
x.config(time: 5, warmup: 2)
|
|
27
|
+
x.report("render 20 components") { pipeline.call(erb_typical, binding_ctx) }
|
|
28
|
+
x.compare!
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
desc "Run memory allocation benchmark; exits 1 if allocations exceed baseline × 1.20"
|
|
33
|
+
task :memory do
|
|
34
|
+
require "memory_profiler"
|
|
35
|
+
require "rails_rsc"
|
|
36
|
+
|
|
37
|
+
manifest = RailsRsc::ClientManifest.from_hash(
|
|
38
|
+
(1..20).to_h do |i|
|
|
39
|
+
["Component#{i}", { "id" => "/assets/Component#{i}.js",
|
|
40
|
+
"name" => "Component#{i}",
|
|
41
|
+
"chunks" => ["/assets/Component#{i}.js"] }]
|
|
42
|
+
end
|
|
43
|
+
)
|
|
44
|
+
pipeline = RailsRsc::RenderPipeline.new(manifest)
|
|
45
|
+
erb_typical = "<div>\n#{(1..20).map { |i| "<Component#{i} index={#{i}} />" }.join("\n")}\n</div>"
|
|
46
|
+
ctx = Object.new
|
|
47
|
+
binding_ctx = ctx.instance_eval { binding }
|
|
48
|
+
|
|
49
|
+
baseline_path = File.expand_path("../../spec/benchmarks/baseline.json", __dir__)
|
|
50
|
+
report = MemoryProfiler.report { pipeline.call(erb_typical, binding_ctx) }
|
|
51
|
+
current = report.total_allocated
|
|
52
|
+
|
|
53
|
+
if File.exist?(baseline_path)
|
|
54
|
+
baseline = JSON.parse(File.read(baseline_path))
|
|
55
|
+
limit = (baseline["typical_allocations"] * 1.20).ceil
|
|
56
|
+
puts "Memory allocations: #{current} (baseline: #{baseline['typical_allocations']}, limit: #{limit})"
|
|
57
|
+
|
|
58
|
+
if current > limit
|
|
59
|
+
warn "[rails_rsc] FAIL: allocations #{current} exceed baseline limit #{limit}"
|
|
60
|
+
exit 1
|
|
61
|
+
else
|
|
62
|
+
puts "[rails_rsc] PASS: allocations within 120% of baseline"
|
|
63
|
+
end
|
|
64
|
+
else
|
|
65
|
+
baseline_data = { "typical_allocations" => current, "heavy_allocations" => nil }
|
|
66
|
+
File.write(baseline_path, JSON.generate(baseline_data))
|
|
67
|
+
puts "[rails_rsc] Baseline established: #{current} allocations. Re-run to compare."
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/tasks/rsc.rake
ADDED
data/sig/ruact.rbs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"typical_allocations":1623,"heavy_allocations":7764}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "rspec-benchmark"
|
|
5
|
+
require "memory_profiler"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
BENCHMARK_BASELINE_FILE = File.expand_path("baseline.json", __dir__)
|
|
9
|
+
|
|
10
|
+
RSpec.describe "RenderPipeline benchmark" do
|
|
11
|
+
include RSpec::Benchmark::Matchers
|
|
12
|
+
|
|
13
|
+
let(:baseline_file) { BENCHMARK_BASELINE_FILE }
|
|
14
|
+
|
|
15
|
+
let(:manifest) do
|
|
16
|
+
entries = (1..20).to_h do |i|
|
|
17
|
+
["Component#{i}", {
|
|
18
|
+
"id" => "/assets/Component#{i}.js",
|
|
19
|
+
"name" => "Component#{i}",
|
|
20
|
+
"chunks" => ["/assets/Component#{i}.js"]
|
|
21
|
+
}]
|
|
22
|
+
end
|
|
23
|
+
Ruact::ClientManifest.from_hash(entries)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
let(:pipeline) { Ruact::RenderPipeline.new(manifest) }
|
|
27
|
+
|
|
28
|
+
def make_erb(count)
|
|
29
|
+
components = (1..count).map { |i| "<Component#{i} index={#{i}} />" }.join("\n")
|
|
30
|
+
"<div>\n#{components}\n</div>"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def render_erb(erb_source, active_pipeline = pipeline)
|
|
34
|
+
ctx = Object.new
|
|
35
|
+
active_pipeline.call(erb_source, ctx.instance_eval { binding })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
describe "typical view (20 components)" do
|
|
39
|
+
let(:erb_typical) { make_erb(20) }
|
|
40
|
+
|
|
41
|
+
it "allocates fewer than the baseline × 1.20 objects" do
|
|
42
|
+
report = MemoryProfiler.report { render_erb(erb_typical) }
|
|
43
|
+
allocations = report.total_allocated
|
|
44
|
+
|
|
45
|
+
if File.exist?(baseline_file)
|
|
46
|
+
baseline = JSON.parse(File.read(baseline_file))
|
|
47
|
+
limit = (baseline["typical_allocations"] * 1.20).ceil
|
|
48
|
+
expect(allocations).to be <= limit,
|
|
49
|
+
"Typical view allocations #{allocations} exceed baseline limit #{limit} " \
|
|
50
|
+
"(baseline: #{baseline['typical_allocations']})"
|
|
51
|
+
else
|
|
52
|
+
# First run — establish baseline
|
|
53
|
+
File.write(baseline_file, JSON.generate("typical_allocations" => allocations,
|
|
54
|
+
"heavy_allocations" => nil))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
describe "heavy view (100 components)" do
|
|
60
|
+
let(:manifest_heavy) do
|
|
61
|
+
entries = (1..100).to_h do |i|
|
|
62
|
+
["Component#{i}", {
|
|
63
|
+
"id" => "/assets/Component#{i}.js",
|
|
64
|
+
"name" => "Component#{i}",
|
|
65
|
+
"chunks" => ["/assets/Component#{i}.js"]
|
|
66
|
+
}]
|
|
67
|
+
end
|
|
68
|
+
Ruact::ClientManifest.from_hash(entries)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
let(:pipeline_heavy) { Ruact::RenderPipeline.new(manifest_heavy) }
|
|
72
|
+
let(:erb_heavy) { make_erb(100) }
|
|
73
|
+
|
|
74
|
+
it "allocates fewer than the baseline × 1.20 objects" do
|
|
75
|
+
report = MemoryProfiler.report { render_erb(erb_heavy, pipeline_heavy) }
|
|
76
|
+
allocations = report.total_allocated
|
|
77
|
+
|
|
78
|
+
if File.exist?(baseline_file)
|
|
79
|
+
data = JSON.parse(File.read(baseline_file))
|
|
80
|
+
if data["heavy_allocations"]
|
|
81
|
+
limit = (data["heavy_allocations"] * 1.20).ceil
|
|
82
|
+
expect(allocations).to be <= limit,
|
|
83
|
+
"Heavy view allocations #{allocations} exceed baseline limit #{limit} " \
|
|
84
|
+
"(baseline: #{data['heavy_allocations']})"
|
|
85
|
+
else
|
|
86
|
+
data["heavy_allocations"] = allocations
|
|
87
|
+
File.write(baseline_file, JSON.generate(data))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Flight Wire Format Fixtures
|
|
2
|
+
|
|
3
|
+
This directory contains fixture files used to test the Flight wire format serializer. Each `.txt` file contains the exact byte output that `Ruact::Flight::Renderer.render` is expected to produce for a given Ruby input.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Wire Format Reference
|
|
8
|
+
|
|
9
|
+
The React Flight wire protocol encodes a component tree as a series of newline-terminated rows:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
<hex-id>:<payload>\n # model row — a JSON value at position <hex-id>
|
|
13
|
+
<hex-id>:I<json-array>\n # import row — registers a client module: [moduleId, exportName, chunks]
|
|
14
|
+
<hex-id>:E<json-error>\n # error row — encodes a serialized error object
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- **Row `0`** is always the root — the main React element tree returned to the client.
|
|
18
|
+
- **Import rows (`I` rows)** always appear *before* the model rows that reference them.
|
|
19
|
+
- Hex IDs start at `0` for the root and increment (`1`, `2`, `a`, `b`, …) for each additional row.
|
|
20
|
+
|
|
21
|
+
### Worked Example — `client_reference.txt`
|
|
22
|
+
|
|
23
|
+
Ruby input:
|
|
24
|
+
```ruby
|
|
25
|
+
manifest = Ruact::ClientManifest.new({"LikeButton" => {moduleId: "/LikeButton.jsx", chunks: ["/LikeButton.jsx"]}})
|
|
26
|
+
ref = manifest.reference_for("LikeButton")
|
|
27
|
+
Ruact::Flight::Renderer.render(ref, manifest)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Expected output (`client_reference.txt`):
|
|
31
|
+
```
|
|
32
|
+
1:I["/LikeButton.jsx","LikeButton",["/LikeButton.jsx"]]
|
|
33
|
+
0:["$","$L1",null,{}]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- Row `1` is the import row — it tells React where to find `LikeButton`.
|
|
37
|
+
- Row `0` is the root element — `["$","$L1",null,{}]` is a React element whose type is `$L1` (a reference to import row 1), with `null` key and empty props `{}`.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## How `match_flight_fixture` Works
|
|
42
|
+
|
|
43
|
+
The custom RSpec matcher is defined in `spec/support/matchers/flight_fixture_matcher.rb`:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
expect(output).to match_flight_fixture("nil")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This reads `spec/fixtures/flight/nil.txt` and performs an **exact string comparison** against `output`. There is no normalisation — whitespace, newlines, and ordering must match exactly.
|
|
50
|
+
|
|
51
|
+
### Failure output
|
|
52
|
+
|
|
53
|
+
When a fixture does not match, the failure message shows both the expected (fixture file content) and actual (serializer output) as inspected strings, making it easy to spot differences in whitespace or character escaping.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Fixture File Inventory
|
|
58
|
+
|
|
59
|
+
| File | What it tests |
|
|
60
|
+
|------|---------------|
|
|
61
|
+
| `nil.txt` | Ruby `nil` serializes to the JSON `null` literal in row 0 |
|
|
62
|
+
| `boolean_true.txt` | Ruby `true` serializes to the JSON `true` literal |
|
|
63
|
+
| `boolean_false.txt` | Ruby `false` serializes to the JSON `false` literal |
|
|
64
|
+
| `number_integer.txt` | Ruby integer (e.g. `42`) serializes to a bare JSON number |
|
|
65
|
+
| `number_float.txt` | Ruby float (e.g. `3.14`) serializes to a bare JSON float |
|
|
66
|
+
| `string_basic.txt` | Plain Ruby string serializes to a JSON double-quoted string |
|
|
67
|
+
| `string_dollar_escape.txt` | Strings starting with `$` are escaped to `$$…` to avoid collision with Flight's `$L` reference syntax |
|
|
68
|
+
| `array.txt` | Ruby array serializes to a JSON array in row 0 |
|
|
69
|
+
| `hash.txt` | Ruby hash serializes to a JSON object in row 0 |
|
|
70
|
+
| `client_reference.txt` | A `ClientReference` (no props) produces an import row (`I`) + root element referencing `$L1` |
|
|
71
|
+
| `client_component_with_props.txt` | A `ClientReference` with props passes them as the fourth element of the root array |
|
|
72
|
+
| `react_element_no_props.txt` | A `ReactElement` with no props produces `["$","<tag>",null,{}]` in row 0 |
|
|
73
|
+
| `as_json_object.txt` | An object responding to `as_json` is serialized via that method; if it resolves to a `ClientReference`, import + root rows are emitted |
|
|
74
|
+
| `serializable_object.txt` | An object including `Ruact::Serializable` and declaring `rsc_props` serializes only the declared props |
|
|
75
|
+
| `redirect_row.txt` | A redirect instruction serializes to a JSON object with `redirectUrl` and `redirectType` keys in row 0 |
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Adding a New Fixture
|
|
80
|
+
|
|
81
|
+
See the [Fixture-First Workflow](../../../CONTRIBUTING.md#fixture-first-workflow-adding-a-new-serializable-type) section in `CONTRIBUTING.md` for the full four-step process.
|
|
82
|
+
|
|
83
|
+
Quick reference:
|
|
84
|
+
|
|
85
|
+
1. Create `spec/fixtures/flight/<type_name>.txt` with the expected wire bytes.
|
|
86
|
+
2. Write a failing spec using `match_flight_fixture("<type_name>")`.
|
|
87
|
+
3. Implement the type handler in `flight/serializer.rb`.
|
|
88
|
+
4. Run `bundle exec rspec` — the new spec must pass, full suite must have no regressions.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0:[1,"a",true,null]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0:false
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0:true
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0:{"debug":true,"count":5,"label":"x"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0:null
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0:3.14
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0:42
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0:["$","div",null,{}]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0:{"redirectUrl":"/posts/1","redirectType":"push"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0:"hello"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0:"$$danger"
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module Ruact
|
|
7
|
+
RSpec.describe ClientManifest do
|
|
8
|
+
let(:manifest_data) do
|
|
9
|
+
{
|
|
10
|
+
"LikeButton" => {
|
|
11
|
+
"id" => "/LikeButton.jsx",
|
|
12
|
+
"name" => "LikeButton",
|
|
13
|
+
"chunks" => ["/LikeButton.jsx"]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
let(:dual_manifest_data) do
|
|
19
|
+
{
|
|
20
|
+
"LikeButton" => {
|
|
21
|
+
"id" => "/LikeButton.jsx",
|
|
22
|
+
"name" => "LikeButton",
|
|
23
|
+
"chunks" => ["/LikeButton.jsx"]
|
|
24
|
+
},
|
|
25
|
+
"posts/_like_button" => {
|
|
26
|
+
"id" => "/posts/_like_button.jsx",
|
|
27
|
+
"name" => "default",
|
|
28
|
+
"chunks" => ["/posts/_like_button.jsx"]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe "#include?" do
|
|
34
|
+
let(:manifest) { described_class.from_hash(manifest_data) }
|
|
35
|
+
|
|
36
|
+
it "returns true for a key present in the manifest" do
|
|
37
|
+
expect(manifest.include?("LikeButton")).to be true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "returns false for a key absent from the manifest" do
|
|
41
|
+
expect(manifest.include?("posts/_like_button")).to be false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
describe "#reference_for with controller_path:" do
|
|
46
|
+
let(:shared_only_manifest) { described_class.from_hash(manifest_data) }
|
|
47
|
+
let(:dual_manifest) { described_class.from_hash(dual_manifest_data) }
|
|
48
|
+
|
|
49
|
+
it "uses shared key when no controller_path given (AC#1)" do
|
|
50
|
+
ref = shared_only_manifest.reference_for("LikeButton")
|
|
51
|
+
expect(ref.module_id).to eq("/LikeButton.jsx")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "uses co-located key when it exists in the manifest (AC#2)" do
|
|
55
|
+
ref = dual_manifest.reference_for("LikeButton", controller_path: "posts")
|
|
56
|
+
expect(ref.module_id).to eq("/posts/_like_button.jsx")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "co-located takes precedence over shared when both exist (AC#3)" do
|
|
60
|
+
ref = dual_manifest.reference_for("LikeButton", controller_path: "posts")
|
|
61
|
+
expect(ref.module_id).to eq("/posts/_like_button.jsx")
|
|
62
|
+
expect(ref.module_id).not_to eq("/LikeButton.jsx")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "falls back to shared when co-located key absent (AC#4)" do
|
|
66
|
+
ref = dual_manifest.reference_for("LikeButton", controller_path: "articles")
|
|
67
|
+
expect(ref.module_id).to eq("/LikeButton.jsx")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it "looks in comments/ first, finds none, uses shared (AC#5)" do
|
|
71
|
+
ref = dual_manifest.reference_for("LikeButton", controller_path: "comments")
|
|
72
|
+
expect(ref.module_id).to eq("/LikeButton.jsx")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "returns the same object for repeated calls (dedup by object_id)" do
|
|
76
|
+
ref1 = dual_manifest.reference_for("LikeButton", controller_path: "posts")
|
|
77
|
+
ref2 = dual_manifest.reference_for("LikeButton", controller_path: "posts")
|
|
78
|
+
expect(ref1).to equal(ref2)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "shared and co-located references are different objects" do
|
|
82
|
+
shared = dual_manifest.reference_for("LikeButton")
|
|
83
|
+
co_loc = dual_manifest.reference_for("LikeButton", controller_path: "posts")
|
|
84
|
+
expect(shared).not_to equal(co_loc)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
describe ".load" do
|
|
89
|
+
let(:loaded_manifest) do
|
|
90
|
+
Tempfile.create(["manifest", ".json"]) do |f|
|
|
91
|
+
f.write(manifest_data.to_json)
|
|
92
|
+
f.flush
|
|
93
|
+
described_class.load(f.path)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "returns a frozen manifest (AC#5)" do
|
|
98
|
+
expect(loaded_manifest).to be_frozen
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "allows reference_for on a frozen manifest without raising (AC#5)" do
|
|
102
|
+
expect { loaded_manifest.reference_for("LikeButton") }.not_to raise_error
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "resolves the correct ClientReference from a frozen manifest (AC#5)" do
|
|
106
|
+
ref = loaded_manifest.reference_for("LikeButton")
|
|
107
|
+
expect(ref).to be_a(Flight::ClientReference)
|
|
108
|
+
expect(ref.module_id).to eq("/LikeButton.jsx")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "raises ManifestError for unknown component with actionable message (AC#1)" do
|
|
112
|
+
expect { loaded_manifest.reference_for("Unknown") }
|
|
113
|
+
.to raise_error(Ruact::ManifestError, /Unknown/)
|
|
114
|
+
expect { loaded_manifest.reference_for("Unknown") }
|
|
115
|
+
.to raise_error(Ruact::ManifestError, /Did you run the Vite build\?/)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
describe ".from_hash" do
|
|
120
|
+
it "returns a mutable manifest (not frozen)" do
|
|
121
|
+
manifest = described_class.from_hash(manifest_data)
|
|
122
|
+
expect(manifest).not_to be_frozen
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|