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,163 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * vite-plugin-ruact
6
+ *
7
+ * Scans app/javascript/components for files with "use client" directives and
8
+ * emits public/react-client-manifest.json so the Rails gem can resolve
9
+ * component names to chunk URLs.
10
+ *
11
+ * Manifest format:
12
+ * {
13
+ * "LikeButton": {
14
+ * "id": "/assets/LikeButton-abc123.js",
15
+ * "name": "LikeButton",
16
+ * "chunks": ["/assets/LikeButton-abc123.js"]
17
+ * }
18
+ * }
19
+ */
20
+ export default function ruact(options = {}) {
21
+ const {
22
+ componentsDir = "app/javascript/components",
23
+ manifestOutput = "public/react-client-manifest.json",
24
+ } = options;
25
+
26
+ let root;
27
+ let manifest = {};
28
+
29
+ return {
30
+ name: "vite-plugin-ruact",
31
+
32
+ configResolved(config) {
33
+ root = config.root;
34
+ },
35
+
36
+ // During dev: build the manifest from source files
37
+ buildStart() {
38
+ manifest = buildManifest(path.resolve(root, componentsDir));
39
+ writeManifest(path.resolve(root, manifestOutput), manifest);
40
+ },
41
+
42
+ // During build: update with hashed chunk URLs from the bundle
43
+ generateBundle(_options, bundle) {
44
+ const updated = {};
45
+
46
+ for (const [chunkFileName, chunk] of Object.entries(bundle)) {
47
+ if (chunk.type !== "chunk") continue;
48
+
49
+ const facadeId = chunk.facadeModuleId;
50
+ if (!facadeId) continue;
51
+
52
+ // Find manifest entries whose source file matches this chunk
53
+ for (const [name, entry] of Object.entries(manifest)) {
54
+ if (facadeId === entry._sourceFile) {
55
+ const url = "/" + chunkFileName;
56
+ updated[name] = {
57
+ id: url,
58
+ name,
59
+ chunks: [url],
60
+ };
61
+ }
62
+ }
63
+ }
64
+
65
+ // Merge: keep entries that didn't get a hashed URL (dev mode)
66
+ const final = { ...manifest, ...updated };
67
+ // Strip internal _sourceFile field
68
+ for (const entry of Object.values(final)) {
69
+ delete entry._sourceFile;
70
+ }
71
+
72
+ writeManifest(path.resolve(root, manifestOutput), final);
73
+ },
74
+
75
+ // Dev server: watch components dir and rebuild manifest on change
76
+ configureServer(server) {
77
+ const dir = path.resolve(root, componentsDir);
78
+ server.watcher.add(dir);
79
+ server.watcher.on("change", (file) => {
80
+ if (file.startsWith(dir)) {
81
+ manifest = buildManifest(dir);
82
+ writeManifest(path.resolve(root, manifestOutput), manifest);
83
+ }
84
+ });
85
+ },
86
+ };
87
+ }
88
+
89
+ function buildManifest(componentsDir) {
90
+ const manifest = {};
91
+
92
+ if (!fs.existsSync(componentsDir)) return manifest;
93
+
94
+ const files = walkDir(componentsDir).filter((f) =>
95
+ /\.(jsx?|tsx?)$/.test(f)
96
+ );
97
+
98
+ for (const file of files) {
99
+ const content = fs.readFileSync(file, "utf8");
100
+ if (!hasUseClient(content)) continue;
101
+
102
+ const exports = extractExportNames(content);
103
+ const relUrl = "/" + path.relative(componentsDir, file);
104
+
105
+ for (const name of exports) {
106
+ manifest[name] = {
107
+ id: relUrl,
108
+ name,
109
+ chunks: [relUrl],
110
+ _sourceFile: file, // used during build to match hashed chunks
111
+ };
112
+ }
113
+ }
114
+
115
+ return manifest;
116
+ }
117
+
118
+ function hasUseClient(content) {
119
+ // "use client" must appear as a directive at the top of the file
120
+ return /^\s*["']use client["']/m.test(content);
121
+ }
122
+
123
+ function extractExportNames(content) {
124
+ const names = new Set();
125
+
126
+ // export function Foo
127
+ // export const Foo
128
+ // export class Foo
129
+ const namedRe = /export\s+(?:default\s+)?(?:function|const|class|let|var)\s+([A-Z][A-Za-z0-9]*)/g;
130
+ let m;
131
+ while ((m = namedRe.exec(content)) !== null) {
132
+ names.add(m[1]);
133
+ }
134
+
135
+ // export { Foo, Bar }
136
+ const bracedRe = /export\s+\{([^}]+)\}/g;
137
+ while ((m = bracedRe.exec(content)) !== null) {
138
+ for (const part of m[1].split(",")) {
139
+ const name = part.trim().split(/\s+as\s+/).pop().trim();
140
+ if (/^[A-Z]/.test(name)) names.add(name);
141
+ }
142
+ }
143
+
144
+ return Array.from(names);
145
+ }
146
+
147
+ function writeManifest(outputPath, manifest) {
148
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
149
+ fs.writeFileSync(outputPath, JSON.stringify(manifest, null, 2));
150
+ }
151
+
152
+ function walkDir(dir) {
153
+ const results = [];
154
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
155
+ const full = path.join(dir, entry.name);
156
+ if (entry.isDirectory()) {
157
+ results.push(...walkDir(full));
158
+ } else {
159
+ results.push(full);
160
+ }
161
+ }
162
+ return results;
163
+ }
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "ruact"
5
+
6
+ module Ruact
7
+ module Generators
8
+ # Installs ruact into the current Rails application.
9
+ #
10
+ # Performs the following actions:
11
+ # 1. Creates config/initializers/ruact.rb
12
+ # 2. Injects `include Ruact::Controller` into ApplicationController
13
+ # 3. Injects the React root div into app/views/layouts/application.html.erb
14
+ # 4. Creates app/javascript/components/.keep
15
+ # 5. Creates vite.config.js (or shows manual instructions if one exists)
16
+ # 6. Creates app/javascript/application.jsx (or skips if one exists)
17
+ #
18
+ # Run: rails generate ruact:install
19
+ class InstallGenerator < Rails::Generators::Base
20
+ source_root File.expand_path("templates", __dir__)
21
+
22
+ desc "Installs ruact into the current Rails application"
23
+
24
+ def create_initializer
25
+ template "initializer.rb.tt", "config/initializers/ruact.rb"
26
+ end
27
+
28
+ def inject_controller_concern
29
+ controller_file = "app/controllers/application_controller.rb"
30
+ return unless File.exist?(destination_root.join(controller_file))
31
+
32
+ content = File.read(destination_root.join(controller_file))
33
+ if content.include?("Ruact::Controller")
34
+ say_status "skip", "Ruact::Controller already included in ApplicationController", :yellow
35
+ return
36
+ end
37
+
38
+ inject_into_file controller_file,
39
+ "\n include Ruact::Controller\n",
40
+ after: /class ApplicationController.*\n/
41
+ end
42
+
43
+ def inject_layout_shell
44
+ layout_file = "app/views/layouts/application.html.erb"
45
+ return unless File.exist?(destination_root.join(layout_file))
46
+
47
+ content = File.read(destination_root.join(layout_file))
48
+ if content.include?("ruact: root")
49
+ say_status "skip", "Rails RSC root already present in layout", :yellow
50
+ return
51
+ end
52
+
53
+ inject_into_file layout_file,
54
+ "\n <%# ruact: root %>\n <div id=\"root\"></div>\n",
55
+ before: " </body>"
56
+ end
57
+
58
+ def create_components_directory
59
+ empty_directory "app/javascript/components"
60
+ create_file "app/javascript/components/.keep" unless
61
+ File.exist?(destination_root.join("app/javascript/components/.keep"))
62
+ end
63
+
64
+ def create_vite_config
65
+ vite_config_file = destination_root.join("vite.config.js")
66
+
67
+ if vite_config_file.exist?
68
+ say_status "notice", "vite.config.js already exists — add the plugin manually:", :yellow
69
+ say " 1. At the top of vite.config.js, add:"
70
+ say " import ruact from '#{Ruact.vite_plugin_path}';"
71
+ say " 2. In the plugins array, add: ruact()"
72
+ say ""
73
+ say " Re-run `rails generate ruact:install --force` to overwrite vite.config.js."
74
+ else
75
+ template "vite.config.js.tt", "vite.config.js"
76
+ end
77
+ end
78
+
79
+ def create_javascript_entry
80
+ template "application.jsx.tt", "app/javascript/application.jsx"
81
+ end
82
+
83
+ def show_post_install_message
84
+ say ""
85
+ say "=" * 60
86
+ say " ruact installed successfully!"
87
+ say "=" * 60
88
+ say ""
89
+ say "Next steps:"
90
+ say " 1. Start the Vite dev server: npm run dev"
91
+ say " 2. Start Rails: rails server"
92
+ say " 3. Add <MyComponent /> to any ERB view"
93
+ say ""
94
+ say "Note: Re-run this generator after updating the ruact gem"
95
+ say "to refresh the bundled Vite plugin path in vite.config.js."
96
+ say ""
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,51 @@
1
+ import { createRoot } from 'react-dom/client';
2
+ import { useState, useEffect } from 'react';
3
+ import { createFromFlightPayload } from './flight-client.js';
4
+ import { setupRouter, teardownRouter } from './rsc-router.js';
5
+
6
+ // MODULE_REGISTRY maps react-client-manifest "id" values to component exports.
7
+ // Add each "use client" component here as you create it.
8
+ // Keys must match the "id" field in public/react-client-manifest.json.
9
+ //
10
+ // Example:
11
+ // import { MyButton } from './components/MyButton.jsx';
12
+ // const MODULE_REGISTRY = { '/MyButton.jsx': { MyButton } };
13
+ const MODULE_REGISTRY = {};
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Boot
17
+ // ---------------------------------------------------------------------------
18
+ const flightData = globalThis.__FLIGHT_DATA;
19
+
20
+ if (!flightData || flightData.length === 0) {
21
+ // Non-RSC page or Rails server not running — skip hydration.
22
+ const root = document.getElementById('root');
23
+ if (root && root.childNodes.length === 0) {
24
+ root.textContent = '[ruact] No Flight data found — is the Rails server running?';
25
+ }
26
+ } else {
27
+ const payload = flightData.join('');
28
+
29
+ let initialTree;
30
+ try {
31
+ initialTree = createFromFlightPayload(payload, MODULE_REGISTRY);
32
+ } catch (err) {
33
+ console.error('[ruact] Failed to parse Flight payload:', err);
34
+ const root = document.getElementById('root');
35
+ if (root) root.textContent = '[ruact] Error: ' + err.message;
36
+ throw err;
37
+ }
38
+
39
+ function App() {
40
+ const [tree, setTree] = useState(() => initialTree);
41
+
42
+ useEffect(() => {
43
+ setupRouter({ onNavigate: setTree, moduleRegistry: MODULE_REGISTRY });
44
+ return () => teardownRouter();
45
+ }, []);
46
+
47
+ return tree;
48
+ }
49
+
50
+ createRoot(document.getElementById('root')).render(<App />);
51
+ }
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ Ruact.configure do |config|
4
+ # Path to the react-client-manifest.json generated by the Vite plugin.
5
+ # Defaults to Rails.root.join("public/react-client-manifest.json").
6
+ # config.manifest_path = Rails.root.join("public", "react-client-manifest.json")
7
+
8
+ # When true, objects without explicit rsc_props declaration raise
9
+ # Ruact::SerializationError instead of falling back to as_json.
10
+ # Recommended: true in production to prevent accidental attribute exposure.
11
+ # config.strict_serialization = Rails.env.production?
12
+
13
+ # Timeout in seconds for deferred Suspense chunks. Default: 5.0.
14
+ # config.suspense_timeout = 5.0
15
+
16
+ # Base URL of the Vite dev server. Default: "http://localhost:5173".
17
+ # config.vite_dev_server = "http://localhost:5173"
18
+ end
@@ -0,0 +1,26 @@
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ // Bundled Vite plugin — path resolved from the installed ruact gem.
4
+ // Re-run `rails generate ruact:install` after gem upgrades to refresh this path.
5
+ import ruact from '<%= Ruact.vite_plugin_path %>';
6
+
7
+ export default defineConfig({
8
+ plugins: [
9
+ react(),
10
+ ruact(), // scans app/javascript/components/ for "use client", emits react-client-manifest.json
11
+ ],
12
+
13
+ build: {
14
+ outDir: 'public/assets',
15
+ manifest: true,
16
+ rollupOptions: {
17
+ input: 'app/javascript/application.jsx',
18
+ },
19
+ },
20
+
21
+ server: {
22
+ port: 5173,
23
+ strictPort: true,
24
+ origin: 'http://localhost:5173',
25
+ },
26
+ });
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ruact
6
+ # Reads the react-client-manifest.json emitted by the Vite plugin and
7
+ # resolves component names to Flight ClientReferences.
8
+ #
9
+ # Manifest format (one entry per "use client" export):
10
+ # {
11
+ # "LikeButton": {
12
+ # "id": "/assets/LikeButton-abc123.js",
13
+ # "chunks": ["/assets/LikeButton-abc123.js"],
14
+ # "name": "LikeButton"
15
+ # },
16
+ # "posts/_like_button": {
17
+ # "id": "/assets/posts/_like_button-abc123.js",
18
+ # "chunks": ["/assets/posts/_like_button-abc123.js"],
19
+ # "name": "default"
20
+ # }
21
+ # }
22
+ class ClientManifest
23
+ # Used by Flight::Serializer to produce I rows.
24
+ # Returns the metadata array the client expects: [id, name, chunks]
25
+ def resolve(module_id, _export_name)
26
+ entry = by_module_id(module_id)
27
+ raise "ClientManifest: no entry for module_id=#{module_id.inspect}" unless entry
28
+
29
+ [entry["id"], entry["name"], entry["chunks"]]
30
+ end
31
+
32
+ # Returns true if +name+ is a top-level key in the manifest data.
33
+ # Used by the dual-path resolver to check co-located key existence before fallback.
34
+ def include?(name)
35
+ entries_by_name.key?(name)
36
+ end
37
+
38
+ # Resolve a component name (e.g. "LikeButton") → ClientReference.
39
+ #
40
+ # When +controller_path+ is provided (e.g. "posts"), the resolver first
41
+ # looks for a co-located key ("posts/_like_button"). If found, it returns
42
+ # that reference; otherwise it falls back to the shared PascalCase key.
43
+ #
44
+ # Returns the same object for repeated calls with the same resolved key
45
+ # (needed for dedup by object_id in Flight::Serializer).
46
+ # Raises if the resolved name is not found in the manifest.
47
+ def reference_for(name, controller_path: nil)
48
+ @reference_cache ||= {}
49
+ key = resolve_key(name, controller_path)
50
+ @reference_cache[key] ||= begin
51
+ entry = entries_by_name[key]
52
+ unless entry
53
+ raise ManifestError,
54
+ "Component #{name.inspect} not found in manifest — " \
55
+ "Did you run the Vite build? Run 'npm run build' or start the Vite dev server."
56
+ end
57
+
58
+ Flight::ClientReference.new(module_id: entry["id"], export_name: name)
59
+ end
60
+ end
61
+
62
+ # Load from a file path (JSON).
63
+ # Pre-warms the reference cache and freezes the manifest so it cannot be
64
+ # mutated at runtime (AC#5). Pre-warming is required because Ruby's freeze
65
+ # is shallow: instance variable assignment on a frozen object raises
66
+ # FrozenError, so @reference_cache must already be set before freeze.
67
+ def self.load(path)
68
+ raw = File.read(path)
69
+ data = JSON.parse(raw)
70
+ manifest = from_hash(data)
71
+ data.each_key { |name| manifest.reference_for(name) }
72
+ manifest.freeze
73
+ end
74
+
75
+ # Build from an already-parsed Hash (useful in tests).
76
+ def self.from_hash(data)
77
+ manifest = new
78
+ manifest.instance_variable_set(:@data, data)
79
+ manifest
80
+ end
81
+
82
+ private
83
+
84
+ # Returns the manifest key to use for +name+ given an optional +controller_path+.
85
+ # Co-located key format: "<controller_path>/_<underscored_name>" (e.g. "posts/_like_button").
86
+ # Co-located takes precedence when both keys exist.
87
+ def resolve_key(name, controller_path)
88
+ return name unless controller_path
89
+
90
+ co_located = "#{controller_path}/_#{rsc_underscore(name)}"
91
+ include?(co_located) ? co_located : name
92
+ end
93
+
94
+ # Converts PascalCase component names to snake_case without requiring ActiveSupport.
95
+ # Equivalent to ActiveSupport::Inflector.underscore for PascalCase inputs.
96
+ def rsc_underscore(name)
97
+ name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
98
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
99
+ .downcase
100
+ end
101
+
102
+ def data
103
+ @data ||= {}
104
+ end
105
+
106
+ # Index by component name for fast lookup
107
+ def entries_by_name
108
+ @entries_by_name ||= data
109
+ end
110
+
111
+ def by_module_id(id)
112
+ data.values.find { |entry| entry["id"] == id }
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ # Holds the client components encountered during ERB rendering.
5
+ # Thread-local so it's safe under concurrent requests.
6
+ module ComponentRegistry
7
+ THREAD_KEY = :__rsc_component_registry__
8
+
9
+ def self.start
10
+ Thread.current[THREAD_KEY] = [] # rubocop:disable Ruact/NoSharedState -- TODO: refactor to explicit arg passing (NFR8)
11
+ end
12
+
13
+ def self.register(name, props)
14
+ token = "__RSC_#{components.length}__"
15
+ components << { token: token, name: name, props: props }
16
+ token
17
+ end
18
+
19
+ def self.components
20
+ Thread.current[THREAD_KEY] ||= [] # rubocop:disable Ruact/NoSharedState -- TODO: refactor to explicit arg passing (NFR8)
21
+ end
22
+
23
+ def self.reset
24
+ Thread.current[THREAD_KEY] = nil # rubocop:disable Ruact/NoSharedState -- TODO: refactor to explicit arg passing (NFR8)
25
+ end
26
+
27
+ def self.by_token(token)
28
+ components.find { |c| c[:token] == token }
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ # Holds gem-wide configuration. Instantiated once via Ruact.config.
5
+ # Configure via Ruact.configure { |c| c.attr = value } in an initializer.
6
+ class Configuration
7
+ # @return [String, nil] Path to react-client-manifest.json.
8
+ # Defaults to Rails.root.join("public/react-client-manifest.json") when nil.
9
+ attr_accessor :manifest_path
10
+
11
+ # @return [Boolean] When true, objects without explicit rsc_props declaration
12
+ # raise Ruact::SerializationError. Defaults to false in development, true in production.
13
+ attr_accessor :strict_serialization
14
+
15
+ # @return [Float] Seconds before a deferred Suspense chunk times out. Default: 5.0.
16
+ attr_accessor :suspense_timeout
17
+
18
+ # @return [String] Base URL of the Vite dev server. Default: "http://localhost:5173".
19
+ attr_accessor :vite_dev_server
20
+
21
+ def initialize
22
+ @manifest_path = nil
23
+ @strict_serialization = begin
24
+ Rails.env.production?
25
+ rescue StandardError
26
+ false
27
+ end
28
+ @suspense_timeout = 5.0
29
+ @vite_dev_server = "http://localhost:5173"
30
+ end
31
+ end
32
+ end