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
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+ require "uri"
6
+
7
+ module Ruact
8
+ # Include in ApplicationController to enable RSC rendering.
9
+ #
10
+ # class ApplicationController < ActionController::Base
11
+ # include Ruact::Controller
12
+ # end
13
+ #
14
+ # After that, any action whose view is a .html.erb file will automatically:
15
+ # - Respond to text/x-component requests with a raw Flight payload
16
+ # - Respond to text/html requests with an HTML shell + inline Flight payload
17
+ module Controller
18
+ extend ActiveSupport::Concern
19
+
20
+ private
21
+
22
+ # Returns the boot-time cached manifest (set by Railtie#config.to_prepare).
23
+ # No per-request file I/O (AC#6).
24
+ def rsc_manifest
25
+ Ruact.manifest
26
+ end
27
+
28
+ # Only activate RSC rendering for HTML-like requests (AC FR26).
29
+ # JSON, XML, and other formats bypass RSC entirely so respond_to blocks
30
+ # and explicit render calls work without interference.
31
+ def default_render
32
+ if rsc_template_exists? && (request.format.html? || rsc_request?)
33
+ rsc_render
34
+ else
35
+ super
36
+ end
37
+ end
38
+
39
+ # Render the RSC view for the current action using ActionView's full pipeline.
40
+ # ActionView handles layouts, partials, and helpers — the ErbPreprocessorHook
41
+ # ensures all PascalCase tags are transformed before template compilation.
42
+ #
43
+ # Called automatically when no explicit render is performed and a matching
44
+ # .html.erb template exists. Can also be called explicitly with options.
45
+ #
46
+ # +template+: logical template name (e.g. "posts/custom"), or nil to use
47
+ # the current action's default template.
48
+ # +locals+: hash of local variables to pass to the template.
49
+ def rsc_render(template: nil, locals: {})
50
+ pipeline = RenderPipeline.new(rsc_manifest, controller_path: controller_path, logger: logger)
51
+ streaming = rsc_request? && self.class.ancestors.include?(ActionController::Live)
52
+
53
+ # ComponentRegistry is started before ActionView renders the template.
54
+ # ViewHelper's __rsc_component__ registers components during rendering.
55
+ # from_html eagerly captures the registry before the ensure block resets it.
56
+ ComponentRegistry.start
57
+ enumerator = begin
58
+ opts = template ? { template: template } : { action: action_name }
59
+ html = render_to_string(opts.merge(layout: false, locals: locals))
60
+ pipeline.from_html(html, streaming: streaming)
61
+ ensure
62
+ ComponentRegistry.reset
63
+ end
64
+
65
+ if rsc_request?
66
+ if streaming
67
+ response.headers["Content-Type"] = "text/x-component; charset=utf-8"
68
+ response.headers["Cache-Control"] = "no-cache"
69
+ response.headers["X-Accel-Buffering"] = "no"
70
+ begin
71
+ enumerator.each { |row| response.stream.write(row) }
72
+ ensure
73
+ response.stream.close
74
+ end
75
+ else
76
+ render plain: enumerator.to_a.join, content_type: "text/x-component"
77
+ end
78
+ else
79
+ render html: rsc_html_shell(enumerator.to_a.join).html_safe, layout: false
80
+ end
81
+ end
82
+
83
+ # Overrides Rails redirect_to for RSC requests: emits a Flight redirect row
84
+ # (`0:{"redirectUrl":"...","redirectType":"push"}`) instead of a 302 response.
85
+ # This allows the client-side router to handle the navigation without an extra
86
+ # HTTP round-trip. Non-RSC requests and external-origin redirects fall through
87
+ # to the standard Rails implementation.
88
+ def redirect_to(options = {}, response_options = {})
89
+ return super unless rsc_request?
90
+
91
+ url = url_for(options)
92
+
93
+ begin
94
+ uri = ::URI.parse(url)
95
+ # External origin: fall back to standard 302 so the browser follows it normally.
96
+ # Compare host, port, and scheme to avoid treating same-host-different-port as same-origin.
97
+ if uri.host
98
+ return super if uri.host != request.host
99
+ return super if uri.port && uri.port != request.port
100
+ return super if uri.scheme && uri.scheme != request.scheme
101
+ end
102
+
103
+ redirect_url = uri.path.nil? || uri.path.empty? ? "/" : uri.path
104
+ redirect_url += "?#{uri.query}" if uri.query
105
+ redirect_url += "##{uri.fragment}" if uri.fragment
106
+ rescue ::URI::InvalidURIError
107
+ return super
108
+ end
109
+
110
+ render plain: "0:#{JSON.generate({ 'redirectUrl' => redirect_url, 'redirectType' => 'push' })}\n",
111
+ content_type: "text/x-component"
112
+ end
113
+
114
+ def rsc_request?
115
+ request.headers["Accept"]&.include?("text/x-component") ||
116
+ request.headers["RSC-Request"] == "1"
117
+ end
118
+
119
+ def rsc_template_exists?
120
+ File.exist?(default_template_path)
121
+ end
122
+
123
+ def default_template_path
124
+ action = action_name
125
+ controller = self.class.name.underscore.sub("_controller", "")
126
+ Rails.root.join("app", "views", controller, "#{action}.html.erb")
127
+ end
128
+
129
+ def rsc_html_shell(flight_payload)
130
+ escaped_payload = flight_payload.gsub("</script>", '<\/script>')
131
+ <<~HTML
132
+ <!DOCTYPE html>
133
+ <html lang="en">
134
+ <head>
135
+ <meta charset="UTF-8" />
136
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
137
+ <title>Rails RSC</title>
138
+ #{vite_tags}
139
+ </head>
140
+ <body>
141
+ <div id="root"></div>
142
+ <script>
143
+ (function() {
144
+ var d = (self.__FLIGHT_DATA = self.__FLIGHT_DATA || []);
145
+ d.push(#{escaped_payload.inspect});
146
+ })();
147
+ </script>
148
+ </body>
149
+ </html>
150
+ HTML
151
+ end
152
+
153
+ def vite_tags
154
+ if Rails.env.development? && vite_dev_running?
155
+ # @vitejs/plugin-react normally injects this preamble by processing index.html.
156
+ # Since our HTML is generated by Rails (not Vite), we inject it manually.
157
+ # Without it, every JSX file throws "can't detect preamble" at runtime.
158
+ react_preamble = <<~JS
159
+ <script type="module">
160
+ import RefreshRuntime from 'http://localhost:5173/@react-refresh';
161
+ RefreshRuntime.injectIntoGlobalHook(window);
162
+ window.$RefreshReg$ = () => {};
163
+ window.$RefreshSig$ = () => (type) => type;
164
+ window.__vite_plugin_react_preamble_installed__ = true;
165
+ </script>
166
+ JS
167
+
168
+ react_preamble + <<~HTML
169
+ <script type="module" src="http://localhost:5173/@vite/client"></script>
170
+ <script type="module" src="http://localhost:5173/app/javascript/application.jsx"></script>
171
+ HTML
172
+ else
173
+ # Production: read hashed URL from Vite manifest
174
+ entry = vite_manifest_entry("app/javascript/application.jsx")
175
+ src = entry ? "/assets/#{entry['file']}" : "/assets/application.js"
176
+ %(<script type="module" src="#{src}"></script>)
177
+ end
178
+ end
179
+
180
+ def vite_dev_running?
181
+ require "socket"
182
+ Socket.tcp("localhost", 5173, connect_timeout: 1).close
183
+ true
184
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError
185
+ false
186
+ end
187
+
188
+ def vite_manifest_entry(src_path)
189
+ manifest_path = Rails.root.join("public", "assets", ".vite", "manifest.json")
190
+ return nil unless File.exist?(manifest_path)
191
+
192
+ JSON.parse(File.read(manifest_path))[src_path]
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "pathname"
5
+
6
+ module Ruact
7
+ # Runs a suite of installation health checks and prints ✓/✗ per check.
8
+ # Extracted from the rsc:doctor Rake task for direct testability (FR27).
9
+ class Doctor
10
+ CHECKS = %i[manifest vite controller layout streaming].freeze
11
+
12
+ # Runs all checks, prints results, returns true if all pass.
13
+ def self.run
14
+ new.run
15
+ end
16
+
17
+ def run
18
+ results = CHECKS.map { |check| send(:"check_#{check}") }
19
+ results.each { |status, message| puts format_result(status, message) }
20
+ passed = results.all? { |status, _| status == :pass }
21
+ puts "Run rails generate ruact:install to fix configuration issues" unless passed
22
+ passed
23
+ end
24
+
25
+ private
26
+
27
+ def check_manifest
28
+ path = manifest_path
29
+ if Pathname(path).exist?
30
+ [:pass, "Manifest found at #{path}"]
31
+ else
32
+ [:fail, "Manifest not found — run vite build"]
33
+ end
34
+ end
35
+
36
+ def check_vite
37
+ TCPSocket.new("localhost", 5173).close
38
+ [:pass, "Vite accessible at localhost:5173"]
39
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
40
+ [:fail, "Vite not accessible at localhost:5173 — run npm run dev"]
41
+ end
42
+
43
+ def check_controller
44
+ path = Rails.root.join("app", "controllers", "application_controller.rb")
45
+ if File.exist?(path) && File.read(path).include?("Ruact::Controller")
46
+ [:pass, "Ruact::Controller included in ApplicationController"]
47
+ else
48
+ [:fail, "Ruact::Controller not included in ApplicationController"]
49
+ end
50
+ end
51
+
52
+ def check_layout
53
+ path = Rails.root.join("app", "views", "layouts", "application.html.erb")
54
+ if File.exist?(path) && File.read(path).include?("ruact: root")
55
+ [:pass, "React shell present in application.html.erb"]
56
+ else
57
+ [:fail, "React shell missing from application.html.erb"]
58
+ end
59
+ end
60
+
61
+ def check_streaming
62
+ mode = Ruact.streaming_mode || :buffered
63
+ label = mode == :enabled ? "enabled" : "buffered"
64
+ [:pass, "streaming: #{label} (#{streaming_server_hint})"]
65
+ end
66
+
67
+ def streaming_server_hint
68
+ return "Puma" if defined?(::Puma)
69
+ return "Unicorn" if defined?(::Unicorn)
70
+ return "Passenger" if defined?(::PhusionPassenger)
71
+
72
+ "unknown"
73
+ end
74
+
75
+ def manifest_path
76
+ Ruact.config.manifest_path ||
77
+ Rails.root.join("public", "react-client-manifest.json")
78
+ end
79
+
80
+ def format_result(status, message)
81
+ status == :pass ? "✓ #{message}" : "✗ #{message}"
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ # Transforms ERB source before Ruby evaluation.
5
+ #
6
+ # It handles one thing: PascalCase component tags with +{expr}+ props.
7
+ #
8
+ # <LikeButton postId={@post.id} initialCount={5} />
9
+ #
10
+ # becomes a placeholder that evaluates the props as Ruby:
11
+ #
12
+ # <%= __rsc_component__("LikeButton", { "postId" => @post.id, "initialCount" => 5 }) %>
13
+ #
14
+ # The placeholder is replaced by an HTML comment with a unique token:
15
+ # <!-- __RSC_COMPONENT_0__ -->
16
+ #
17
+ # The actual ClientReference + props are registered in the binding and
18
+ # collected by HtmlConverter after the ERB renders.
19
+ class ErbPreprocessor
20
+ # Matches a PascalCase opening tag with optional attributes and optional self-closing.
21
+ # Examples:
22
+ # <Button />
23
+ # <LikeButton postId={@post.id} initialCount={5} />
24
+ # <Dialog open={true}>
25
+ COMPONENT_TAG_RE = %r{<([A-Z][A-Za-z0-9]*)(\s[^>]*)?\s*/?>}
26
+
27
+ # Matches <Suspense ...> opening tags (handled before general PascalCase processing).
28
+ SUSPENSE_OPEN_RE = /<Suspense\b([^>]*?)>/m
29
+ SUSPENSE_CLOSE_RE = %r{</Suspense>}
30
+
31
+ # Matches a +{ruby_expr}+ attribute value — captures everything between the braces.
32
+ # We use a simple bracket-depth counter approach during scanning instead of regex
33
+ # because expressions can contain nested braces: {foo.bar({ a: 1 })}.
34
+ PROP_RE = /\b([a-zA-Z_][a-zA-Z0-9_]*)=\{/
35
+
36
+ # Transform ERB source, replacing component tags with ERB placeholders.
37
+ # Returns the transformed source string.
38
+ def self.transform(source)
39
+ new.transform(source)
40
+ end
41
+
42
+ def transform(source)
43
+ # Step 1: transform <Suspense> paired tags into <rsc-suspense> HTML elements.
44
+ # This runs before the general component regex so Suspense isn't treated as a component.
45
+ result = source
46
+ .gsub(SUSPENSE_OPEN_RE) do
47
+ attrs = ::Regexp.last_match(1)
48
+ fallback = extract_string_attr(attrs, "fallback") || ""
49
+ escaped = fallback.gsub('"', "&quot;")
50
+ %(<rsc-suspense data-rsc-fallback="#{escaped}">)
51
+ end
52
+ .gsub(SUSPENSE_CLOSE_RE, "</rsc-suspense>")
53
+
54
+ # Step 2: transform remaining PascalCase self-closing / opening component tags.
55
+ result.gsub(COMPONENT_TAG_RE) do |match|
56
+ component_name = ::Regexp.last_match(1)
57
+ attrs_string = ::Regexp.last_match(2).to_s.strip
58
+ match_start = ::Regexp.last_match.begin(0)
59
+ line = result[0...match_start].count("\n") + 1
60
+
61
+ begin
62
+ props_ruby = parse_props(attrs_string)
63
+ props_hash = props_ruby.empty? ? "{}" : "{ #{props_ruby} }"
64
+ %(<%= __rsc_component__(#{component_name.inspect}, #{props_hash}) %>)
65
+ rescue PreprocessorError => e
66
+ raise PreprocessorError, "#{e.message} at line #{line}: #{match.strip}"
67
+ end
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ # Extract a string attribute value (double or single quoted) from an attrs string.
74
+ def extract_string_attr(attrs, name)
75
+ m = attrs.match(/\b#{Regexp.escape(name)}\s*=\s*"([^"]*)"/) ||
76
+ attrs.match(/\b#{Regexp.escape(name)}\s*=\s*'([^']*)'/)
77
+ m&.[](1)
78
+ end
79
+
80
+ # Parses the attributes string of a component tag and returns a Ruby
81
+ # fragment representing a Hash literal, e.g.:
82
+ # "postId" => @post.id, "initialCount" => 5
83
+ def parse_props(attrs_string)
84
+ return "" if attrs_string.empty?
85
+
86
+ pairs = []
87
+ remaining = attrs_string.dup
88
+
89
+ while (m = PROP_RE.match(remaining))
90
+ prop_name = m[1]
91
+ # Find the matching closing brace, respecting nesting
92
+ value_start = m.end(0)
93
+ value_expr = extract_braced_expr(remaining, value_start)
94
+ pairs << "#{prop_name.inspect} => #{value_expr}"
95
+ # Advance past this prop
96
+ remaining = remaining[(value_start + value_expr.length + 1)..] # +1 for closing }
97
+ break if remaining.nil?
98
+ end
99
+
100
+ pairs.join(", ")
101
+ end
102
+
103
+ # Given a string and a start position (just after the opening '{'),
104
+ # returns the content up to the matching '}'.
105
+ def extract_braced_expr(str, start)
106
+ depth = 1
107
+ i = start
108
+ while i < str.length && depth.positive?
109
+ case str[i]
110
+ when "{" then depth += 1
111
+ when "}" then depth -= 1
112
+ end
113
+ i += 1
114
+ end
115
+ raise PreprocessorError, "unclosed brace in prop expression" if depth.positive?
116
+
117
+ str[start...(i - 1)]
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ # Module prepended into ActionView::Template::Handlers::ERB via Railtie.
5
+ # Applies the RSC preprocessor to every ERB template source before
6
+ # ActionView compiles it — transparent to views, layouts, and partials.
7
+ #
8
+ # ErbPreprocessor.transform has a fast-path O(1) return when the source
9
+ # contains no PascalCase tags, so non-RSC templates pay essentially no cost.
10
+ #
11
+ # Idempotent: prepend is a no-op if this module is already in the ancestor
12
+ # chain, so reloads in development mode are safe.
13
+ module ErbPreprocessorHook
14
+ # Called by ActionView for every ERB template. +source+ is the raw ERB
15
+ # text; the return value is Ruby code that ActionView will eval.
16
+ def call(template, source)
17
+ super(template, ErbPreprocessor.transform(source))
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ class Error < StandardError; end
5
+
6
+ # Raised when react-client-manifest.json is absent or a component is not found in it.
7
+ class ManifestError < Error; end
8
+
9
+ # Raised when a Ruby value cannot be serialized as a React prop.
10
+ class SerializationError < Error; end
11
+
12
+ # Raised when the ERB preprocessor encounters a malformed component tag.
13
+ class PreprocessorError < Error; end
14
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module Flight
5
+ # Represents a React element: <div>, <Component>, etc.
6
+ # Wire format: ["$", type, key, props]
7
+ class ReactElement
8
+ attr_reader :type, :key, :props
9
+
10
+ def initialize(type:, key: nil, props: {})
11
+ @type = type
12
+ @key = key
13
+ @props = props
14
+ end
15
+ end
16
+
17
+ # Represents a React Suspense boundary.
18
+ # `fallback` is a ReactElement shown while the deferred content is loading.
19
+ # `children` is the actual content (emitted as a deferred row after `delay` seconds).
20
+ class SuspenseElement
21
+ attr_reader :fallback, :children, :delay
22
+
23
+ def initialize(fallback:, children:, delay: 1.5)
24
+ @fallback = fallback
25
+ @children = children
26
+ @delay = delay
27
+ end
28
+ end
29
+
30
+ # Points to a "use client" module — will become an I row + $L<id> reference.
31
+ class ClientReference
32
+ attr_reader :module_id, :export_name
33
+
34
+ def initialize(module_id:, export_name: "default")
35
+ @module_id = module_id
36
+ @export_name = export_name
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ruact
6
+ module Flight
7
+ # Renders a React element tree to a Flight wire format string.
8
+ #
9
+ # Usage:
10
+ # output = Renderer.render(root_element, bundler_config)
11
+ # # => "1:I[...]\n0:[\"$\",\"div\",...]\n"
12
+ class Renderer
13
+ def self.render(model, bundler_config, **)
14
+ each(model, bundler_config, streaming: false, **).to_a.join
15
+ end
16
+
17
+ def self.each(model, bundler_config, strict_serialization: false, on_as_json_warning: nil,
18
+ streaming: true, &)
19
+ new(model, bundler_config,
20
+ strict_serialization: strict_serialization,
21
+ on_as_json_warning: on_as_json_warning).each(streaming: streaming, &)
22
+ end
23
+
24
+ def initialize(model, bundler_config, strict_serialization: false, on_as_json_warning: nil)
25
+ @request = Request.new(model, bundler_config,
26
+ strict_serialization: strict_serialization,
27
+ on_as_json_warning: on_as_json_warning)
28
+ end
29
+
30
+ # Yields Flight rows one at a time.
31
+ # Flush order: imports → regular → root → deferred (with optional delay) → errors.
32
+ # When streaming: false (initial HTML shell), deferred delays are skipped.
33
+ def each(streaming: true, &block)
34
+ return enum_for(:each, streaming: streaming) unless block_given?
35
+
36
+ root_id = @request.allocate_id # => 0
37
+
38
+ serializer = Serializer.new(@request)
39
+ root_value = serializer.serialize_model(@request.root_model)
40
+ root_json = JSON.generate(root_value)
41
+ root_row = RowEmitter.model(root_id, root_json)
42
+
43
+ @request.completed_import_chunks.each(&block)
44
+ @request.completed_regular_chunks.each(&block)
45
+ yield root_row
46
+
47
+ # Deferred chunks: emitted after root, optionally delayed (Suspense streaming).
48
+ # When the chunk delay exceeds suspense_timeout, an E-type error row is emitted instead.
49
+ @request.deferred_chunks.each do |deferred|
50
+ if streaming && deferred[:delay]&.positive?
51
+ timeout = Ruact.config.suspense_timeout
52
+ if timeout&.positive? && deferred[:delay] > timeout
53
+ yield RowEmitter.error(deferred[:id], JSON.generate("Suspense timeout exceeded"))
54
+ next
55
+ end
56
+ sleep(deferred[:delay])
57
+ end
58
+
59
+ # Serialize deferred content — may produce new import rows
60
+ import_count_before = @request.completed_import_chunks.length
61
+ deferred_value = serializer.serialize_model(deferred[:element])
62
+
63
+ # Yield any import rows discovered during deferred serialization
64
+ @request.completed_import_chunks[import_count_before..].each(&block)
65
+
66
+ yield RowEmitter.model(deferred[:id], JSON.generate(deferred_value))
67
+ end
68
+
69
+ @request.completed_error_chunks.each(&block)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module Flight
5
+ # Central state for a single Flight render.
6
+ # Owns the ID allocator, chunk queues, and dedup tracker.
7
+ class Request
8
+ # I rows — flushed first
9
+ attr_reader :completed_import_chunks
10
+ # model rows
11
+ attr_reader :completed_regular_chunks
12
+ # E rows — flushed last
13
+ attr_reader :completed_error_chunks
14
+ # { id:, element:, delay: } — emitted after root row
15
+ attr_reader :deferred_chunks
16
+ # object_id => "$L<hex>" reference (dedup)
17
+ attr_reader :written_objects
18
+ attr_reader :next_chunk_id, :pending_chunks, :bundler_config, :root_model,
19
+ :strict_serialization, :on_as_json_warning
20
+
21
+ def initialize(model, bundler_config, strict_serialization: false, on_as_json_warning: nil)
22
+ @strict_serialization = strict_serialization
23
+ @on_as_json_warning = on_as_json_warning
24
+ @next_chunk_id = 0
25
+ @pending_chunks = 0
26
+ @bundler_config = bundler_config
27
+
28
+ @completed_import_chunks = []
29
+ @completed_regular_chunks = []
30
+ @completed_error_chunks = []
31
+ @deferred_chunks = []
32
+
33
+ @written_objects = {}.compare_by_identity
34
+
35
+ # Root task is always ID 0
36
+ @root_model = model
37
+ end
38
+
39
+ def allocate_id
40
+ id = @next_chunk_id
41
+ @next_chunk_id += 1
42
+ id
43
+ end
44
+
45
+ def increment_pending
46
+ @pending_chunks += 1
47
+ end
48
+
49
+ def decrement_pending
50
+ @pending_chunks -= 1
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module Flight
5
+ # Formats Flight wire format rows.
6
+ #
7
+ # Text rows: <hex_id>:<tag><json_payload>\n
8
+ # Binary rows: <hex_id>:<tag><hex_byte_length>,<binary_data> (no newline)
9
+ module RowEmitter
10
+ # A plain model row (most elements, objects, arrays)
11
+ def self.model(id, json)
12
+ "#{id.to_s(16)}:#{json}\n"
13
+ end
14
+
15
+ # An import row — tells the client where to load a "use client" module
16
+ def self.import(id, metadata_json)
17
+ "#{id.to_s(16)}:I#{metadata_json}\n"
18
+ end
19
+
20
+ # An error row
21
+ def self.error(id, error_json)
22
+ "#{id.to_s(16)}:E#{error_json}\n"
23
+ end
24
+
25
+ # A large text row (binary framing, no trailing newline)
26
+ def self.text(id, text)
27
+ byte_length = text.bytesize
28
+ "#{id.to_s(16)}:T#{byte_length.to_s(16)},#{text}"
29
+ end
30
+
31
+ # A hint row — no ID, fire-and-forget preload signal
32
+ def self.hint(code, model_json)
33
+ ":H#{code}#{model_json}\n"
34
+ end
35
+ end
36
+ end
37
+ end