reactive_views 0.1.0
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/CHANGELOG.md +49 -0
- data/LICENSE.txt +22 -0
- data/README.md +134 -0
- data/app/controllers/reactive_views/bundles_controller.rb +47 -0
- data/app/frontend/reactive_views/boot.ts +215 -0
- data/config/routes.rb +10 -0
- data/lib/generators/reactive_views/install/install_generator.rb +645 -0
- data/lib/generators/reactive_views/install/templates/application.html.erb.tt +19 -0
- data/lib/generators/reactive_views/install/templates/application.js.tt +9 -0
- data/lib/generators/reactive_views/install/templates/boot.ts.tt +225 -0
- data/lib/generators/reactive_views/install/templates/example_component.tsx.tt +4 -0
- data/lib/generators/reactive_views/install/templates/initializer.rb.tt +7 -0
- data/lib/generators/reactive_views/install/templates/vite.config.mts.tt +78 -0
- data/lib/generators/reactive_views/install/templates/vite.json.tt +22 -0
- data/lib/reactive_views/cache_store.rb +269 -0
- data/lib/reactive_views/component_resolver.rb +243 -0
- data/lib/reactive_views/configuration.rb +71 -0
- data/lib/reactive_views/controller_props.rb +43 -0
- data/lib/reactive_views/css_strategy.rb +179 -0
- data/lib/reactive_views/engine.rb +14 -0
- data/lib/reactive_views/error_overlay.rb +1390 -0
- data/lib/reactive_views/full_page_renderer.rb +158 -0
- data/lib/reactive_views/helpers.rb +209 -0
- data/lib/reactive_views/props_builder.rb +42 -0
- data/lib/reactive_views/props_inference.rb +89 -0
- data/lib/reactive_views/railtie.rb +93 -0
- data/lib/reactive_views/renderer.rb +484 -0
- data/lib/reactive_views/resolver.rb +66 -0
- data/lib/reactive_views/ssr_process.rb +274 -0
- data/lib/reactive_views/tag_transformer.rb +523 -0
- data/lib/reactive_views/temp_file_manager.rb +81 -0
- data/lib/reactive_views/template_handler.rb +52 -0
- data/lib/reactive_views/version.rb +5 -0
- data/lib/reactive_views.rb +58 -0
- data/lib/tasks/reactive_views.rake +104 -0
- data/node/ssr/server.mjs +965 -0
- data/package-lock.json +516 -0
- data/package.json +14 -0
- metadata +322 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: bb9182ae58f918f62a36d2c0ea69b4ae30b2c2d6e7a517897b3ce221ea382573
|
|
4
|
+
data.tar.gz: 6828c01ab50233811706d1dfc8c3f1d961e87c72c54e4058942410f9d7cce338
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ec6677f6dbe14e769bcec14f3f94bae2584573036fde8dbcc2fe4e2c098bf29d6e7b32b4f3b5bcbadb6d5d0d08baa65eb5265cfe19d5444fe6c09b2f42c3ea4d
|
|
7
|
+
data.tar.gz: 1e66dc6799b26439f3db59644b097faaf9bb52ad720f3e47bb258e0c2e40ada312e9139669ff1c8e48416d711ee1def940bf6020054a05f7bb8eb7a4c3382988
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the ReactiveViews gem will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **Generator Idempotency**: The install generator can now be run multiple times safely. It properly detects and removes old `vite_client_tag` and `vite_javascript_tag` calls, replacing them with the unified `reactive_views_script_tag` helper.
|
|
9
|
+
- **Helper Robustness**: The `reactive_views_script_tag` helper now gracefully handles cases where `vite_rails` is not available or misconfigured, showing helpful error messages in development and failing silently in production.
|
|
10
|
+
- **Layout Transformation**: Improved regex patterns to catch all variations of Vite tags (extra whitespace, different quote styles, multiline).
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Comprehensive Test Suite**: Full RSpec test coverage including:
|
|
14
|
+
- Unit tests for TagTransformer, Renderer, ComponentResolver, ErrorOverlay, and Helpers
|
|
15
|
+
- Integration tests for component rendering pipeline and generator
|
|
16
|
+
- Dummy Rails app for integration testing using Combustion
|
|
17
|
+
- WebMock for SSR server mocking
|
|
18
|
+
- SimpleCov for code coverage reporting
|
|
19
|
+
- **Rake Tasks**: New rake tasks for test management:
|
|
20
|
+
- `rake reactive_views:test:all` - Run all tests
|
|
21
|
+
- `rake reactive_views:test:unit` - Run unit tests only
|
|
22
|
+
- `rake reactive_views:test:integration` - Run integration tests only
|
|
23
|
+
- `rake reactive_views:test:coverage` - Run with coverage report
|
|
24
|
+
- `rake reactive_views:test:clean` - Clean test artifacts
|
|
25
|
+
- **Testing Documentation**: Comprehensive testing guide in `TESTING.md`
|
|
26
|
+
- **Generator Force Mode**: The install generator now respects `--force` flag to skip prompts during automated setups
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- **Generator Update Logic**: The `update_application_layout` method now:
|
|
30
|
+
- Uses multiline regex patterns (`/m` flag) for robust tag detection
|
|
31
|
+
- Removes existing `reactive_views_script_tag` before adding new one to prevent duplicates
|
|
32
|
+
- Better handles edge cases like extra whitespace and comment blocks
|
|
33
|
+
- Provides clearer status messages during updates
|
|
34
|
+
|
|
35
|
+
### Development
|
|
36
|
+
- Added development dependencies:
|
|
37
|
+
- rspec-rails ~> 6.0
|
|
38
|
+
- capybara ~> 3.39
|
|
39
|
+
- selenium-webdriver ~> 4.16
|
|
40
|
+
- webdrivers ~> 5.3
|
|
41
|
+
- webmock ~> 3.19
|
|
42
|
+
- simplecov ~> 0.22
|
|
43
|
+
- combustion ~> 1.4
|
|
44
|
+
- sqlite3 ~> 1.4
|
|
45
|
+
|
|
46
|
+
## [Previous Versions]
|
|
47
|
+
|
|
48
|
+
See git history for changes in previous versions.
|
|
49
|
+
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Elison Campos
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
|
22
|
+
|
data/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# ReactiveViews
|
|
2
|
+
|
|
3
|
+
[](https://github.com/elisoncampos/reactive_views/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/reactive_views)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
ReactiveViews lets you write React components and use them directly inside Rails views — with **server-side rendering (SSR)**, **client hydration**, and an optional **full-page `.tsx.erb` / `.jsx.erb`** pipeline. No separate frontend app required.
|
|
8
|
+
|
|
9
|
+
## Disclaimer
|
|
10
|
+
|
|
11
|
+
**⚠️ ReactiveViews is in beta.** Expect some breaking changes as the API and defaults settle. If you run it in production, treat the Node SSR runtime as part of your app (monitor it, capture logs, and load test SSR-heavy pages).
|
|
12
|
+
|
|
13
|
+
## What you get
|
|
14
|
+
|
|
15
|
+
- **React islands in ERB**: write `<UserBadge />` in `.html.erb` and get SSR + hydration.
|
|
16
|
+
- **Full-page React templates**: render entire Rails pages from `.tsx.erb` / `.jsx.erb` while keeping Rails controllers, routes, and layouts.
|
|
17
|
+
- **Fast SSR**: batch rendering for flat pages, tree rendering for nested component composition.
|
|
18
|
+
- **Less prop plumbing**: optional TypeScript-based prop key inference for full-page pages (reduces payload size).
|
|
19
|
+
- **Caching knobs**: SSR HTML caching + inference caching with pluggable cache stores.
|
|
20
|
+
- **Vite-native**: dev HMR + production builds via `vite_rails`.
|
|
21
|
+
|
|
22
|
+
## A quick taste
|
|
23
|
+
|
|
24
|
+
### Islands inside ERB
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
// app/views/components/user_badge.tsx
|
|
28
|
+
type Props = { fullName: string };
|
|
29
|
+
|
|
30
|
+
export default function UserBadge({ fullName }: Props) {
|
|
31
|
+
return <strong className="UserBadge">{fullName}</strong>;
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```erb
|
|
36
|
+
<!-- app/views/users/show.html.erb -->
|
|
37
|
+
<UserBadge fullName="<%= @user.name %>" />
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Full-page `.tsx.erb`
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# app/controllers/users_controller.rb
|
|
44
|
+
class UsersController < ApplicationController
|
|
45
|
+
def index
|
|
46
|
+
@users = User.select(:id, :name).order(:name)
|
|
47
|
+
reactive_view_props(current_user: current_user)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
// app/views/users/index.tsx.erb
|
|
54
|
+
interface Props {
|
|
55
|
+
users: Array<{ id: number; name: string }>;
|
|
56
|
+
current_user: { name: string } | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default function UsersIndex({ users, current_user }: Props) {
|
|
60
|
+
return (
|
|
61
|
+
<main>
|
|
62
|
+
<h1>Users</h1>
|
|
63
|
+
{current_user ? (
|
|
64
|
+
<p>Signed in as {current_user.name}</p>
|
|
65
|
+
) : (
|
|
66
|
+
<p>Not signed in</p>
|
|
67
|
+
)}
|
|
68
|
+
<ul>
|
|
69
|
+
{users.map((u) => (
|
|
70
|
+
<li key={u.id}>{u.name}</li>
|
|
71
|
+
))}
|
|
72
|
+
</ul>
|
|
73
|
+
</main>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Install (5 minutes)
|
|
79
|
+
|
|
80
|
+
1. Add the gem + install:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
gem "reactive_views"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
bundle install
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
2. Run the installer:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
bundle exec rails generate reactive_views:install
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
3. Start dev:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
bin/dev
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
That runs Rails + Vite + the SSR server together.
|
|
103
|
+
|
|
104
|
+
For the layout helper and a full walkthrough, see [`docs/getting-started.md`](docs/getting-started.md).
|
|
105
|
+
|
|
106
|
+
## Documentation
|
|
107
|
+
|
|
108
|
+
Start at [`docs/README.md`](docs/README.md). Handy links:
|
|
109
|
+
|
|
110
|
+
- **Quick start:** [`docs/getting-started.md`](docs/getting-started.md)
|
|
111
|
+
- **Islands in ERB:** [`docs/islands.md`](docs/islands.md)
|
|
112
|
+
- **Full-page `.tsx.erb` / `.jsx.erb`:** [`docs/full-page-tsx-erb.md`](docs/full-page-tsx-erb.md)
|
|
113
|
+
- **Configuration:** [`docs/configuration.md`](docs/configuration.md)
|
|
114
|
+
- **SSR runtime & process management:** [`docs/ssr.md`](docs/ssr.md)
|
|
115
|
+
- **Caching:** [`docs/caching.md`](docs/caching.md)
|
|
116
|
+
- **Production deployment:** [`docs/production-deployment.md`](docs/production-deployment.md)
|
|
117
|
+
- **Troubleshooting:** [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
|
118
|
+
- **Security considerations:** [`docs/security.md`](docs/security.md)
|
|
119
|
+
- **API reference:** [`docs/api-reference.md`](docs/api-reference.md)
|
|
120
|
+
|
|
121
|
+
## Before you ship
|
|
122
|
+
|
|
123
|
+
- Read [`docs/security.md`](docs/security.md) (props are public; SSR should stay private).
|
|
124
|
+
- Read [`docs/production-deployment.md`](docs/production-deployment.md) (assets, SSR process strategy, what to monitor).
|
|
125
|
+
|
|
126
|
+
## Contributing
|
|
127
|
+
|
|
128
|
+
At this time, we are only accepting bug reports. If you encounter any issues or have suggestions, please open an issue on our [GitHub repository](https://github.com/elisoncampos/reactive_views/issues).
|
|
129
|
+
|
|
130
|
+
We appreciate your feedback and contributions to help improve ReactiveViews!
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
|
|
5
|
+
module ReactiveViews
|
|
6
|
+
class BundlesController < ActionController::Base
|
|
7
|
+
# Proxy full-page bundle requests to the local SSR server.
|
|
8
|
+
# This allows the client to fetch bundles via same-origin requests,
|
|
9
|
+
# even when the SSR server is only listening on localhost.
|
|
10
|
+
def show
|
|
11
|
+
bundle_key = params[:id]
|
|
12
|
+
|
|
13
|
+
# Validate bundle key format (should be a SHA1 hash)
|
|
14
|
+
unless bundle_key.match?(/\A[a-f0-9]{40}\z/)
|
|
15
|
+
render plain: "Invalid bundle key", status: :bad_request
|
|
16
|
+
return
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Ensure SSR process is running
|
|
20
|
+
SsrProcess.ensure_running
|
|
21
|
+
|
|
22
|
+
# Proxy the request to the SSR server
|
|
23
|
+
ssr_url = ReactiveViews.config.ssr_url
|
|
24
|
+
uri = URI.parse("#{ssr_url}/full-page-bundles/#{bundle_key}.js")
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
28
|
+
http.open_timeout = 5
|
|
29
|
+
http.read_timeout = 10
|
|
30
|
+
|
|
31
|
+
response = http.get(uri.path)
|
|
32
|
+
|
|
33
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
34
|
+
# Stream the JavaScript bundle back to the client
|
|
35
|
+
render body: response.body,
|
|
36
|
+
content_type: "application/javascript",
|
|
37
|
+
status: :ok
|
|
38
|
+
else
|
|
39
|
+
render plain: "Bundle not found", status: :not_found
|
|
40
|
+
end
|
|
41
|
+
rescue Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout => e
|
|
42
|
+
Rails.logger.error("[ReactiveViews] Bundle proxy error: #{e.message}")
|
|
43
|
+
render plain: "SSR server unavailable", status: :service_unavailable
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { hydrateRoot } from 'react-dom/client';
|
|
3
|
+
|
|
4
|
+
type IslandSpec = {
|
|
5
|
+
id: string;
|
|
6
|
+
component: string;
|
|
7
|
+
el: Element;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type ReactiveViewsGlobal = {
|
|
11
|
+
react?: typeof React;
|
|
12
|
+
hydrateRoot?: typeof hydrateRoot;
|
|
13
|
+
ssrUrl?: string;
|
|
14
|
+
hydratedPages?: string[];
|
|
15
|
+
lastPageError?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
declare global {
|
|
19
|
+
interface Window {
|
|
20
|
+
__REACTIVE_VIEWS__?: ReactiveViewsGlobal;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const globalRV: ReactiveViewsGlobal = (window.__REACTIVE_VIEWS__ ||= {});
|
|
25
|
+
globalRV.react = React;
|
|
26
|
+
globalRV.hydrateRoot = hydrateRoot;
|
|
27
|
+
globalRV.hydratedPages ||= [];
|
|
28
|
+
|
|
29
|
+
function readProps(uuid: string) {
|
|
30
|
+
const script = document.querySelector(`script[type="application/json"][data-island-uuid="${uuid}"]`);
|
|
31
|
+
if (!script) return {};
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(script.textContent || '{}');
|
|
34
|
+
} catch (_e) {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type PageMetadata = {
|
|
40
|
+
props?: Record<string, unknown>;
|
|
41
|
+
bundle?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function readPageMetadata(uuid: string): PageMetadata {
|
|
45
|
+
const script = document.querySelector(`script[type="application/json"][data-page-uuid="${uuid}"]`);
|
|
46
|
+
if (!script) return {};
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(script.textContent || '{}');
|
|
49
|
+
} catch (_e) {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toPath(name: string): string {
|
|
55
|
+
// Convert PascalCase and dot notation to snake_case path
|
|
56
|
+
const parts = name.split('.');
|
|
57
|
+
const snake = (s: string) => s.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2').replace(/([a-z\d])([A-Z])/g, '$1_$2').replace(/-/g, '_').toLowerCase();
|
|
58
|
+
return parts.map(snake).join('/');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Import all components eagerly so they are available for hydration
|
|
62
|
+
const rawComponentsFromViews = import.meta.glob('../../views/components/**/*.{tsx,jsx,ts,js}', {
|
|
63
|
+
eager: true,
|
|
64
|
+
});
|
|
65
|
+
const rawComponentsFromJs = import.meta.glob('../components/**/*.{tsx,jsx,ts,js}', {
|
|
66
|
+
eager: true,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Normalize paths to match the absolute aliased paths that loadComponent expects
|
|
70
|
+
const componentsFromViews: Record<string, any> = {};
|
|
71
|
+
const componentsFromJs: Record<string, any> = {};
|
|
72
|
+
|
|
73
|
+
for (const [path, module] of Object.entries(rawComponentsFromViews)) {
|
|
74
|
+
const normalized = path.replace('../../views/components/', '/app/views/components/');
|
|
75
|
+
componentsFromViews[normalized] = (module as any).default;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const [path, module] of Object.entries(rawComponentsFromJs)) {
|
|
79
|
+
const normalized = path.replace('../components/', '/app/javascript/components/');
|
|
80
|
+
componentsFromJs[normalized] = (module as any).default;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function loadComponent(name: string) {
|
|
84
|
+
// Attempt both conventional locations; try multiple filename shapes and extensions
|
|
85
|
+
const rel = toPath(name);
|
|
86
|
+
const leaf = rel.split('/').pop() as string;
|
|
87
|
+
const bases = ['/app/views/components', '/app/javascript/components'];
|
|
88
|
+
const exts = ['.tsx', '.jsx', '.ts', '.js'];
|
|
89
|
+
|
|
90
|
+
// Combine both glob maps
|
|
91
|
+
const allComponents = { ...componentsFromViews, ...componentsFromJs };
|
|
92
|
+
|
|
93
|
+
// Try both the original name (e.g., "InteractiveCounter") and snake_case ("interactive_counter")
|
|
94
|
+
const nameVariants = [name, rel];
|
|
95
|
+
const leafVariants = [name, leaf];
|
|
96
|
+
|
|
97
|
+
for (const base of bases) {
|
|
98
|
+
for (let i = 0; i < nameVariants.length; i++) {
|
|
99
|
+
const nameVar = nameVariants[i];
|
|
100
|
+
const leafVar = leafVariants[i];
|
|
101
|
+
|
|
102
|
+
for (const ext of exts) {
|
|
103
|
+
const candidates = [
|
|
104
|
+
`${base}/${nameVar}${ext}`,
|
|
105
|
+
`${base}/${nameVar}/index${ext}`,
|
|
106
|
+
`${base}/${nameVar}/${leafVar}${ext}`,
|
|
107
|
+
];
|
|
108
|
+
for (const path of candidates) {
|
|
109
|
+
// Check if this path exists in our glob imports
|
|
110
|
+
if (allComponents[path]) {
|
|
111
|
+
// Component is already loaded with eager: true
|
|
112
|
+
// The .default was already extracted during normalization
|
|
113
|
+
return allComponents[path];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
throw new Error(`Component not found on client: ${name}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function resolveSsrUrl() {
|
|
124
|
+
if (globalRV.ssrUrl) return globalRV.ssrUrl.replace(/\/$/, '');
|
|
125
|
+
const meta = document.querySelector('meta[name="reactive-views-ssr-url"]');
|
|
126
|
+
if (meta?.getAttribute('content')) {
|
|
127
|
+
return meta.getAttribute('content')!.replace(/\/$/, '');
|
|
128
|
+
}
|
|
129
|
+
return 'http://localhost:5175';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function hydrateFullPages() {
|
|
133
|
+
const nodes = Array.from(document.querySelectorAll<HTMLElement>('[data-reactive-page="true"]'));
|
|
134
|
+
for (const node of nodes) {
|
|
135
|
+
const uuid = node.dataset.pageUuid;
|
|
136
|
+
if (!uuid) continue;
|
|
137
|
+
|
|
138
|
+
// Skip if already hydrated
|
|
139
|
+
if (node.dataset.reactiveHydrated === 'true') continue;
|
|
140
|
+
|
|
141
|
+
const metadata = readPageMetadata(uuid);
|
|
142
|
+
if (!metadata.bundle) continue;
|
|
143
|
+
|
|
144
|
+
const ssrUrl = resolveSsrUrl();
|
|
145
|
+
const moduleUrl = `${ssrUrl}/full-page-bundles/${metadata.bundle}.js`;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
if (import.meta.env?.DEV) {
|
|
149
|
+
console.log(`[reactive_views] Hydrating full page bundle: ${metadata.bundle}`);
|
|
150
|
+
}
|
|
151
|
+
const mod = await import(/* @vite-ignore */ moduleUrl);
|
|
152
|
+
const Comp = mod.default || mod.Component || mod;
|
|
153
|
+
hydrateRoot(node, React.createElement(Comp, metadata.props || {}));
|
|
154
|
+
if (import.meta.env?.DEV) {
|
|
155
|
+
console.log(`[reactive_views] Hydrated full page bundle ${metadata.bundle}`);
|
|
156
|
+
}
|
|
157
|
+
globalRV.hydratedPages!.push(uuid);
|
|
158
|
+
node.dataset.reactiveHydrated = "true";
|
|
159
|
+
} catch (e) {
|
|
160
|
+
if (import.meta.env?.DEV) {
|
|
161
|
+
console.error(`[reactive_views] Failed to hydrate full page bundle: ${metadata.bundle}`, e);
|
|
162
|
+
}
|
|
163
|
+
globalRV.lastPageError = e instanceof Error ? e.stack : String(e);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function hydrateIslands() {
|
|
169
|
+
const nodes = Array.from(document.querySelectorAll('[data-island-uuid][data-component]'));
|
|
170
|
+
for (const node of nodes) {
|
|
171
|
+
const el = node as HTMLElement;
|
|
172
|
+
|
|
173
|
+
// Skip if already hydrated
|
|
174
|
+
if (el.dataset.reactiveHydrated === 'true') continue;
|
|
175
|
+
|
|
176
|
+
const uuid = el.dataset.islandUuid!;
|
|
177
|
+
const component = el.dataset.component!;
|
|
178
|
+
try {
|
|
179
|
+
const Comp = await loadComponent(component);
|
|
180
|
+
const props = readProps(uuid);
|
|
181
|
+
hydrateRoot(el, React.createElement(Comp, props));
|
|
182
|
+
el.dataset.reactiveHydrated = 'true';
|
|
183
|
+
} catch (e) {
|
|
184
|
+
if (import.meta.env?.DEV) {
|
|
185
|
+
console.error(`[reactive_views] Failed to hydrate component: ${component}`, e);
|
|
186
|
+
console.error(`[reactive_views] Make sure vite.config.ts has resolve.alias configured for /app/views/components and /app/javascript/components`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function hydrateAll() {
|
|
193
|
+
await hydrateFullPages();
|
|
194
|
+
await hydrateIslands();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Initial hydration
|
|
198
|
+
if (document.readyState === 'loading') {
|
|
199
|
+
document.addEventListener('DOMContentLoaded', hydrateAll);
|
|
200
|
+
} else {
|
|
201
|
+
hydrateAll();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Turbo integration: re-hydrate after Turbo navigations
|
|
205
|
+
// This ensures React components work correctly with Turbo Drive and Turbo Frames
|
|
206
|
+
document.addEventListener('turbo:load', hydrateAll);
|
|
207
|
+
document.addEventListener('turbo:frame-load', hydrateAll);
|
|
208
|
+
|
|
209
|
+
// Clean up before Turbo caches the page for back/forward navigation
|
|
210
|
+
// Without this, restored pages have data-reactive-hydrated="true" but no actual React instances
|
|
211
|
+
document.addEventListener('turbo:before-cache', () => {
|
|
212
|
+
document.querySelectorAll('[data-reactive-hydrated]').forEach(el => {
|
|
213
|
+
el.removeAttribute('data-reactive-hydrated');
|
|
214
|
+
});
|
|
215
|
+
});
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
ReactiveViews::Engine.routes.draw do
|
|
4
|
+
# Proxy full-page bundle requests to the SSR server
|
|
5
|
+
# The client requests: /reactive_views/full-page-bundles/:bundle_key.js
|
|
6
|
+
# We match both with and without the .js extension for flexibility
|
|
7
|
+
get "full-page-bundles/:id", to: "bundles#show", as: :bundle,
|
|
8
|
+
constraints: { id: /[a-f0-9]+/ },
|
|
9
|
+
defaults: { format: "js" }
|
|
10
|
+
end
|