Async Assets
Overview
Midwest ships one compiled stylesheet and one compiled script. Every page that renders three components pays for all 112.
Async assets is the alternative: a small shared core plus one stylesheet and one ES module per component the page actually rendered. It is opt-in, and the single bundle remains the default and stays fully supported.
For a page rendering twelve components:
| raw | gzip | |
|---|---|---|
| single bundle (CSS + JS) | 758 KB | 129 KB |
| async (core + 12 components) | 278 KB | 60 KB |
| reduction | 64.5% | 54.7% |
Turning it on
# config/initializers/midwest.rbMidwest.configure do |config| config.assets.async = trueend<%# app/views/layouts/application.html.erb %><head> <%= midwest_asset_tags %></head>midwest_asset_tags works in both modes — with async off it emits the single bundled stylesheet, exactly as before — so the layout does not need to know which mode it is in. Swapping stylesheet_link_tag "midwest" for it is safe on its own.
Every layout needs it. A layout still carrying
stylesheet_link_tag "midwest"keeps working, but pages using it get the whole bundle and no per-component JavaScript at all — so components render unstyled and inert. This is easy to miss when a second layout only backs a handful of pages. Check them all:grep -rL midwest_asset_tags app/views/layouts/*.erbNote: this is
Midwest.config.assets, not Rails'config.assets(Sprockets/Propshaft). The names are unfortunately similar; setting one does nothing to the other.
Building the artifacts
bin/rails midwest:assets:asyncConsuming apps do not need to run this — the artifacts ship inside the gem. It matters only when working on Midwest itself.
How it knows what a page rendered
Rails renders the view template before the layout, so by the time <head> is evaluated, Midwest already knows exactly which components the page rendered. Nested components register themselves as they render, so a button that renders an icon brings the icon's stylesheet with no dependency graph involved.
A component occasionally needs assets it does not itself render — its element carries another component's Stimulus controller, or wears a class defined in another component's stylesheet. Midwest's own components declare those internally, so this is nothing you configure for stock components; they simply arrive complete. The only time it reaches you is if you eject a component or hand-write data-controller="midwest-…" markup instead of rendering the component — then pull the piece it leans on with config.assets.always or config.assets.preload.
Layout chrome
Components rendered by the layout itself, after <head> has been emitted, are necessarily too late for that. Name them once:
config.assets.always = %i[layout mobile_nav]In development, any component that renders too late logs a warning naming itself and the exact line to add, so you do not have to work this out in advance.
Fragment caching
Use midwest_cache instead of cache around anything containing Midwest components.
<%# Wrong — the component loses its styling on every cache hit %><% cache @product do %> <%= midwest_card { @product.name } %><% end %><%# Right %><% midwest_cache @product do %> <%= midwest_card { @product.name } %><% end %>On a cache hit the block never runs, so the components inside it never render and never reach the registry — but their markup is still on the page, replayed from the cache. Plain cache therefore produces an unstyled component, and only on cache hits, so it will not reproduce locally with caching off.
midwest_cache takes the same arguments as cache and records which components the fragment rendered, storing that list inside the cached fragment. On a hit it reads the list back and re-registers them, so the right stylesheets are emitted either way. Components nested inside the cached ones are covered too. In bundled mode it is simply cache.
The safety net
If you include Midwest::AsyncAssets, then in development any component whose markup is on the page but which never rendered is logged:
[midwest] badge_component appears in the markup but did not render this request,so no stylesheet was emitted. Usually a cached fragment (use `midwest_cache`instead of `cache`), or a component rendered by the layout after <head>(add it to `config.assets.always`).This catches the general problem, not just caching: anything that puts component markup on a page without rendering the component — cached fragments, markup rendered earlier and stored, or layout chrome rendered after <head>.
Your app's own utility classes
Midwest's stylesheet is built from Midwest's markup. If your own views use Tailwind utilities, generate them in your app's own stylesheet — do not rely on Midwest's.
This holds in both modes. Midwest's build runs with Tailwind's automatic content detection off and an explicit list of its own source globs, so midwest.css contains only what Midwest's own components need — nothing from your application, and nothing from Midwest's demo.
It was not always so. Detection used to be left on, scanning the whole project directory, and utilities used only by other markup could land in midwest.css by accident. That shipped ~26 KB of Midwest's own demo-site utilities to every consuming app, and it meant the compiled bundle could change because of a file that had nothing to do with it. If you upgraded across that change and something in your own views lost its styling, this is why.
The symptom is distinctive: your pages go subtly wrong — spacing, z-index, positioning — while the Midwest components on them look perfectly fine. The fix is a Tailwind build for your app scanning your own app/views, app/helpers and app/javascript.
This site hit exactly that, and the fix is in the repo: demo/app/assets/stylesheets/demo.tailwind.css is a minimal entry — no preflight, no theme, no tokens, no component CSS, since Midwest's core already supplies those — that generates only the utilities the demo's own markup asks for. Load it after midwest_asset_tags, because Midwest's core declares the cascade layer order and layer order is fixed by first appearance.
Turbo Frames and Streams
A fragment has no layout and no <head>, so midwest_asset_tags never runs for it. Include the concern and its assets are attached automatically — appended to the document head for Streams, and injected inside the returned <turbo-frame> for Frames:
class ApplicationController < ActionController::Base include Midwest::AsyncAssetsendWhere the tags land matters. Turbo's frame renderer keeps only what is inside the matched <turbo-frame> and discards the rest, so assets for a frame have to sit inside it. Tags placed there are honoured: a <link rel="stylesheet"> anywhere in the document applies, and Turbo re-activates any <script> it swaps into a frame — so both the styles and the per-component modules take effect with nothing to bootstrap.
Components that fetch their own content
Some components issue their own Turbo request as you interact with them, and the fragment that comes back renders Midwest components the page never had a reason to load:
- Autocomplete (
turbo_frame_url:) — the dropdown is empty until the first keystroke, then a frame arrives full of option and avatar markup. - Command palette (
turbo_frame_url:) — the command list is fetched when the palette opens. - Tables (
Midwest::TurboTable) — sorting, filtering and pagination replace the table frame with freshly rendered rows. - Remote dialogs (
midwest_dialog remote: true+Midwest::DialogableConcern) — the dialog body is fetched into its frame when opened, rendering whatever components that action's view uses.
The first two are gem-shipped frame partials and deliver their own assets (below); the last two render your views, so they need approach 1.
The requesting page's <head> was evaluated long before that fragment existed, so midwest_asset_tags cannot have accounted for it. It arrives styled only if something delivers its assets alongside it. There are two ways, and you generally want the first:
1. Include the concern once, app-wide. Midwest::AsyncAssets attaches the right assets to every Turbo Frame and Stream response automatically, so all three components above — and any fragment your own controllers render — are covered with no per-endpoint work:
class ApplicationController < ActionController::Base include Midwest::AsyncAssetsend2. Let the fragment deliver its own. In a frame partial you write — or a gem-shipped one, where the consumer can't reach the controller — emit the tags inside the frame with midwest_frame_assets:
<%= turbo_frame_tag "suggestions" do %> <%= midwest_frame_assets :autocomplete_option, :avatar %> <%# …options… %><% end %>midwest_frame_assets writes real <link>/<script> tags rather than the controller-driven midwest_assets element — a frame arriving on a page that never loaded Midwest has no midwest-assets controller to do the injecting, but a plain <link> needs nothing to run. Naming a component brings what it renders too (an avatar brings its icon), and it marks those components emitted, so an app-wide Midwest::AsyncAssets won't append a second copy. Async mode only; in bundled mode the page's single stylesheet already carries everything.
Midwest's built-in autocomplete and command-palette frame partials already call it, so those two are styled whether or not you include the concern. Midwest::TurboTable renders a partial you write, so a table frame relies on approach 1 (or a midwest_frame_assets call in your own partial — the table frame's <table> is flow content, so raw tags are valid there).
One caveat on placement.
midwest_frame_assetsemits real<link>/<script>tags, so put them where those are valid markup — a frame whose content is a<ul>,<div>or<table>. Don't emit them into a frame whose immediate child is<tr>or<option>; the HTML parser hoists stray tags out of<tbody>/<select>. For those, deliver over a Turbo Stream (the concern appends to<head>) instead.
The same reasoning covers anything you defer over Turbo yourself — a lazy turbo_frame you drop into a tab panel, a dialog, or a bare frame. A tab panel's own content is rendered eagerly with the page and needs nothing extra; but the moment you replace it with a <turbo-frame src="…">, that fragment is subject to the same rule. Include the concern, or call midwest_frame_assets inside the frame.
Components that arrive later
If you know a component will arrive over Turbo, prefetch its stylesheet with the page so the fragment is not styled a beat late:
config.assets.preload = %i[table dialog]Serving from a CDN
config.assets.host = "https://cdn.example.com"The artifacts are served by a Rack mount the engine installs, not through the app's asset pipeline — Rollup emits relative imports between its chunks, and neither Propshaft nor Sprockets rewrites JS import specifiers, so digesting them would break every inter-chunk import. Their filenames already carry content hashes, so they are served immutable.
JavaScript
Per-component modules follow async unless you say otherwise:
config.assets.js = false # per-component CSS onlyEach module registers its controllers through a shared runtime, which adopts your application automatically if you follow the Rails convention of window.Stimulus = Application.start(). No wiring needed. If your app does not expose it that way:
import { Application } from "@hotwired/stimulus"const application = Application.start()window.Midwest.useApplication(application)Registration does not depend on load order. Midwest's modules usually execute before your application boots, and anything that arrives early is queued and flushed the moment an application appears.
Options
| Option | Default | Purpose |
|---|---|---|
async |
false |
Per-component delivery instead of the single bundle |
js |
follows async |
Emit per-component modules as well as stylesheets |
always |
[] |
Components rendered by the layout, after <head> |
preload |
[] |
Prefetch hints for components that arrive over Turbo |
host |
nil |
Serve the artifacts from a CDN |
This site runs on it
Every preview you are looking at is served this way. A preview page loads the shared core plus the files for the components it renders — 132–153 KB depending on the preview, against 765.7 KB of bundle on every page before the switch.
The demo also does not call registerMidwestControllers: each component's controller arrives as its own module and registers itself. So if a preview's interactions work — a banner counting down, a table sorting, a dialog opening — that is per-component JavaScript doing it.
Trade-offs
- Request count. A twelve-component page makes about eighteen requests instead of two. Fine over HTTP/2; consider the bundle if you are still on HTTP/1.1.
- The core is cached across pages, so the saving grows the more pages a visitor sees.
- Bridgetown is bundled-only for now.