Vite Frontend
Overview
The application uses Vite for modern JavaScript/TypeScript builds with React.
- Build Tool: Vite 5.4 with vite-plugin-ruby
- Framework: React 16.13 with TypeScript
- Directory:
app/frontend/
Directory Structure
app/frontend/
├── entrypoints/
│ └── application.tsx # Main entry point, mounts DonationsApp
├── components/
│ ├── DonationsApp.tsx # Main React application
│ └── donations/ # Donation-specific components
├── api/ # Generated route helpers (js_from_routes)
├── lib/ # Shared utilities
└── globals.d.ts # TypeScript declarations
Development
Running the Development Server
# Start all services (Rails, Vite, Sidekiq)
foreman start -f Procfile.dev
# Or start Vite separately
bin/vite dev
Building Assets
# Development build
RAILS_ENV=development rails assets:precompile
# Production build
RAILS_ENV=production rails assets:precompile
Linting
yarn lint # Lint JavaScript/TypeScript (Biome)
yarn typecheck # TypeScript type checking
Production Builds
One image is built per commit and staging, the review apps and production all run that same image, with the compiled assets baked into it. Two properties follow from that. Both are settled in the build configuration rather than checked by CI, so this page also says how to verify each by hand.
The build is reproducible
Rollup names each chunk after the hash of its own output, so a build that is
not reproducible does not merely miss a cache — it renames files. The image is
published as a multi-arch manifest built natively on one runner per platform,
so assets:precompile runs twice, and in production the two runs disagreed:
the two web replicas sat on nodes of different architectures, each served HTML
naming chunks the other did not have, and the Service round-robined, so about
half of all asset requests came back as the Rails 404 page — a white screen on
the donations app.
The architectures were the messenger, not the cause. The same divergence
reproduces on one machine by changing nothing but how many cores the build may
use (taskset -c 0 yarn build against a plain yarn build). Two races were
behind it, and vite.config.mts closes both:
| Setting | Race it closes |
|---|---|
build.commonjsOptions.strictRequires: true | @rollup/plugin-commonjs defaults to 'auto', deciding per CommonJS module whether to hoist or lazily wrap it from state that accumulates as modules load. Whichever importer reaches a shared module first fixes the choice for the whole build. true wraps every module unconditionally — no state, no race, ~2 kB gzipped. |
build.rollupOptions.maxParallelFileOps: 1 | Module order inside a chunk follows the order loads complete. Read in parallel, that order is a property of the machine; read one at a time, it is a property of the module graph. Costs nothing measurable — the build is CPU-bound in terser. |
Nothing enforces this automatically, so it is worth knowing how to check it by hand: build twice with different concurrency and compare.
# The output directory has to be empty each time, or the second listing still
# carries the first build's files and the digests differ for the wrong reason.
rm -rf public/vite && yarn build
find public/vite -type f | sort | xargs sha256sum | sha256sum
rm -rf public/vite && taskset -c 0 yarn build
find public/vite -type f | sort | xargs sha256sum | sha256sum
Two different digests mean the output depends on the machine again — most likely a dependency bump that reintroduced one of the races above. It takes about a minute and reproduces the whole class of failure without needing a second architecture.
No asset host is compiled in
Reports.asset_host returns nil whenever ASSETS_PRECOMPILE is set,
whatever ASSETS_HOST says. A hostname resolved during the build is not a
header runtime can correct — it is written into the output: Sprockets rewrites
every url() in the compiled CSS (twelve font files) and vite_rails passes
the same value to Vite as its base. Compiled with a host, the image carries
one environment's CDN into all of them, and its asset digests change with it.
So the compiled assets are host-free and resolve against whatever origin served
the page, while ASSETS_HOST still applies at runtime, where every environment
has its own. To confirm it after a change, precompile the way the Dockerfile
does and look at what ended up in the CSS:
# example.env stands in for the settings a few initializers read at boot,
# exactly as the Docker build does.
set -a && . ./example.env && set +a
ASSETS_PRECOMPILE=1 RAILS_ENV=production SECRET_KEY_BASE_DUMMY=1 \
ASSETS_HOST=assets.aleteia.org bin/rails assets:precompile
grep -o 'url(//[^)]*' public/assets/*.css # must print nothing
An absolute URL reaching back into /assets/ or /vite/ means a host was
resolved during the build and the image is no longer portable.
React App Mounting
The DonationsApp is mounted directly in app/frontend/entrypoints/application.tsx:
import React from 'react'
import ReactDOM from 'react-dom'
import DonationsApp from '../components/DonationsApp'
function mountDonationsApp() {
const container = document.getElementById('donations-app')
const propsScript = document.getElementById('donations-app-props')
if (container && propsScript) {
const props = JSON.parse(propsScript.textContent || '{}')
ReactDOM.render(<DonationsApp {...props} />, container)
}
}
Passing Props from Rails
In the view (app/views/donations/home/index.html.erb):
<div id="donations-app"></div>
<script type="application/json" id="donations-app-props">
<%= {
stripeKey: ENV.fetch('STRIPE_PUBLISHABLE_KEY'),
gtmContainerId: donations_gtm_container_id
}.to_json.html_safe %>
</script>
Route Helpers
Rails routes are exposed to JavaScript via js_from_routes. Generated helpers are in app/frontend/api/.
import { donationsStripe } from '../api'
// Usage
api.post(donationsStripe.checkout.path(), params)
api.post(donationsStripe.paymentIntent.path(), params)
To regenerate after route changes:
bin/rails js_from_routes:generate
Configuration Files
| File | Purpose |
|---|---|
vite.config.mts | Vite configuration |
config/vite.json | Vite Ruby settings |
tsconfig.json | TypeScript configuration |
biome.json | Linter configuration |
Layout Integration
The donations layout (app/views/layouts/donations.html.erb) includes:
<%= vite_react_refresh_tag %>
<%= vite_client_tag %>
<%= vite_javascript_tag 'entrypoints/application.tsx' %>
Troubleshooting
Vite Dev Server Not Starting
- Check that port 3036 is available
- Ensure Node.js 20.x is installed
Assets Not Loading
- Make sure
bin/vite devis running in development - Check that assets were precompiled for production
React App Not Mounting
- Verify
#donations-appdiv exists in the view - Check browser console for JavaScript errors
- Ensure props JSON is valid
Dependencies
Core
vite(^5.4.0)vite-plugin-ruby(^5.1.1)@vitejs/plugin-react(^4.7.0)react/react-dom(^16.13.0)
Development
typescript(^4.9.5)@biomejs/biome(linting)