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
|
@@ -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
|