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.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +166 -0
  3. data/.rubocop.yml +89 -0
  4. data/CHANGELOG.md +32 -0
  5. data/README.md +35 -0
  6. data/RELEASING.md +203 -0
  7. data/Rakefile +10 -0
  8. data/SECURITY.md +62 -0
  9. data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +163 -0
  10. data/lib/generators/ruact/install/install_generator.rb +100 -0
  11. data/lib/generators/ruact/install/templates/application.jsx.tt +51 -0
  12. data/lib/generators/ruact/install/templates/initializer.rb.tt +18 -0
  13. data/lib/generators/ruact/install/templates/vite.config.js.tt +26 -0
  14. data/lib/ruact/client_manifest.rb +115 -0
  15. data/lib/ruact/component_registry.rb +31 -0
  16. data/lib/ruact/configuration.rb +32 -0
  17. data/lib/ruact/controller.rb +195 -0
  18. data/lib/ruact/doctor.rb +84 -0
  19. data/lib/ruact/erb_preprocessor.rb +120 -0
  20. data/lib/ruact/erb_preprocessor_hook.rb +20 -0
  21. data/lib/ruact/errors.rb +14 -0
  22. data/lib/ruact/flight/react_element.rb +40 -0
  23. data/lib/ruact/flight/renderer.rb +73 -0
  24. data/lib/ruact/flight/request.rb +54 -0
  25. data/lib/ruact/flight/row_emitter.rb +37 -0
  26. data/lib/ruact/flight/serializer.rb +215 -0
  27. data/lib/ruact/flight.rb +12 -0
  28. data/lib/ruact/html_converter.rb +159 -0
  29. data/lib/ruact/railtie.rb +99 -0
  30. data/lib/ruact/render_pipeline.rb +107 -0
  31. data/lib/ruact/serializable.rb +58 -0
  32. data/lib/ruact/version.rb +5 -0
  33. data/lib/ruact/view_helper.rb +23 -0
  34. data/lib/ruact.rb +48 -0
  35. data/lib/rubocop/cop/ruact/no_extend_self.rb +46 -0
  36. data/lib/rubocop/cop/ruact/no_io_in_flight.rb +72 -0
  37. data/lib/rubocop/cop/ruact/no_shared_state.rb +49 -0
  38. data/lib/rubocop/cop/ruact.rb +5 -0
  39. data/lib/tasks/benchmark.rake +70 -0
  40. data/lib/tasks/rsc.rake +9 -0
  41. data/sig/ruact.rbs +4 -0
  42. data/spec/benchmarks/baseline.json +1 -0
  43. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +92 -0
  44. data/spec/fixtures/flight/README.md +88 -0
  45. data/spec/fixtures/flight/array.txt +1 -0
  46. data/spec/fixtures/flight/as_json_object.txt +2 -0
  47. data/spec/fixtures/flight/boolean_false.txt +1 -0
  48. data/spec/fixtures/flight/boolean_true.txt +1 -0
  49. data/spec/fixtures/flight/client_component_with_props.txt +2 -0
  50. data/spec/fixtures/flight/client_reference.txt +2 -0
  51. data/spec/fixtures/flight/hash.txt +1 -0
  52. data/spec/fixtures/flight/nil.txt +1 -0
  53. data/spec/fixtures/flight/number_float.txt +1 -0
  54. data/spec/fixtures/flight/number_integer.txt +1 -0
  55. data/spec/fixtures/flight/react_element_no_props.txt +1 -0
  56. data/spec/fixtures/flight/redirect_row.txt +1 -0
  57. data/spec/fixtures/flight/serializable_object.txt +2 -0
  58. data/spec/fixtures/flight/string_basic.txt +1 -0
  59. data/spec/fixtures/flight/string_dollar_escape.txt +1 -0
  60. data/spec/ruact/client_manifest_spec.rb +126 -0
  61. data/spec/ruact/controller_spec.rb +213 -0
  62. data/spec/ruact/doctor_spec.rb +234 -0
  63. data/spec/ruact/erb_preprocessor_hook_spec.rb +52 -0
  64. data/spec/ruact/erb_preprocessor_spec.rb +89 -0
  65. data/spec/ruact/errors_spec.rb +43 -0
  66. data/spec/ruact/flight/renderer_spec.rb +122 -0
  67. data/spec/ruact/flight/serializer_spec.rb +453 -0
  68. data/spec/ruact/html_converter_spec.rb +147 -0
  69. data/spec/ruact/install_generator_spec.rb +212 -0
  70. data/spec/ruact/railtie_spec.rb +156 -0
  71. data/spec/ruact/render_pipeline_spec.rb +474 -0
  72. data/spec/ruact/serializable_spec.rb +53 -0
  73. data/spec/ruact/view_helper_spec.rb +46 -0
  74. data/spec/spec_helper.rb +16 -0
  75. data/spec/support/matchers/flight_fixture_matcher.rb +25 -0
  76. data/spec/support/rails_stub.rb +45 -0
  77. data/vendor/javascript/vite-plugin-ruact/index.js +163 -0
  78. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ruact/no_io_in_flight"
4
+ require_relative "ruact/no_shared_state"
5
+ require_relative "ruact/no_extend_self"
@@ -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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :rsc do
4
+ desc "Check rails_rsc installation and configuration (FR27)"
5
+ task doctor: :environment do
6
+ require "rails_rsc/doctor"
7
+ exit 1 unless RailsRsc::Doctor.run
8
+ end
9
+ end
data/sig/ruact.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Ruact
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
@@ -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,2 @@
1
+ 1:I["/PostCard.jsx","PostCard",["/PostCard.jsx"]]
2
+ 0:["$","$L1",null,{"post":{"id":1,"title":"Hello","author":"Alice","likesCount":5}}]
@@ -0,0 +1 @@
1
+ 0:false
@@ -0,0 +1 @@
1
+ 0:true
@@ -0,0 +1,2 @@
1
+ 1:I["/CounterButton.jsx","CounterButton",["/CounterButton.jsx"]]
2
+ 0:["$","$L1",null,{"initialCount":0,"label":"Click me","disabled":false}]
@@ -0,0 +1,2 @@
1
+ 1:I["/LikeButton.jsx","LikeButton",["/LikeButton.jsx"]]
2
+ 0:["$","$L1",null,{}]
@@ -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,2 @@
1
+ 1:I["/PostCard.jsx","PostCard",["/PostCard.jsx"]]
2
+ 0:["$","$L1",null,{"post":{"id":1,"title":"Hello"}}]
@@ -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