Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,8 @@ config.auto_load_bundle = false

**Note**: HMR-related FOUC in development mode (dynamic CSS injection) is separate from this SSR auto-loading issue. See Shakapacker docs for details.

**Note**: If you're using Tailwind CSS or another utility-first framework and see layout jumps (sidebars collapsing, flex containers stacking), you may need to inline critical CSS. See [Tailwind/Utility-First CSS FOUC](../deployment/troubleshooting.md#type-2-tailwindutility-first-css-frameworks) for the solution.

#### 3. "document is not defined" errors during SSR

**Problem**: Server-side rendering fails with browser-only API access.
Expand Down
83 changes: 82 additions & 1 deletion docs/deployment/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Having issues with React on Rails? This guide covers the most common problems an
| **Compilation** | Webpack errors, build failures | [Build Issues](#-build-issues) |
| **Runtime** | Components not rendering, JavaScript errors | [Runtime Issues](#-runtime-issues) |
| **CSS Modules** | Styles undefined, SSR CSS crashes | [CSS Modules Issues](#-css-modules-issues) |
| **Styling (FOUC)** | Unstyled content flash with SSR | [Flash of Unstyled Content](#flash-of-unstyled-content-fouc) |
| **Styling (FOUC)** | Unstyled content flash, layout jumps | [Flash of Unstyled Content](#flash-of-unstyled-content-fouc) |
| **Server Rendering** | SSR not working, hydration mismatches | [SSR Issues](#-server-side-rendering-issues) |
| **Performance** | Slow builds, large bundles, memory issues | [Performance Issues](#-performance-issues) |

Expand Down Expand Up @@ -168,6 +168,10 @@ useEffect(() => {

### "Flash of Unstyled Content (FOUC)"

There are two common causes of FOUC in React on Rails applications:

#### Type 1: SSR with `auto_load_bundle`

**Symptoms:** Page briefly shows unstyled content before CSS loads, particularly with SSR and `auto_load_bundle`

**Root Cause:** When using `auto_load_bundle = true` with server-side rendering, `react_component` calls trigger `append_stylesheet_pack_tag` during body rendering, but these appends must execute BEFORE the `stylesheet_pack_tag` in the `<head>`.
Expand All @@ -193,6 +197,83 @@ useEffect(() => {
</html>
```

#### Type 2: Tailwind/Utility-First CSS Frameworks

**Symptoms:** Layout appears broken or jumps on initial page load—sidebars collapse, flex containers stack vertically, backgrounds are white instead of colored.

**Root Cause:** When using Tailwind CSS (or similar utility-first frameworks), your layout HTML contains CSS classes like `flex`, `h-screen`, `bg-slate-100` that have no effect until the CSS bundle loads. The browser renders the raw HTML structure without any styling.

**Example of problematic layout:**

```erb
<!-- These classes do nothing until Tailwind CSS loads -->
<div class="flex flex-row h-screen w-screen">
<div class="flex flex-col bg-slate-100 min-w-[400px]">
<!-- sidebar -->
</div>
<div class="flex-1 overflow-y-auto">
<!-- main content -->
</div>
</div>
```

**Solution:** Inline critical CSS for layout-affecting properties in the `<head>` before your main stylesheet loads. Use stable semantic selectors (not Tailwind utility class names) so the critical CSS doesn't drift when you add or remove utility classes.

**Step 1:** Add semantic classes to your layout's structural elements (alongside existing Tailwind classes):

```erb
<body class="app-body bg-white">
<div class="app-shell flex flex-row h-screen w-screen">
<div class="app-sidebar flex flex-col overflow-y-auto p-5 bg-slate-100 ...">
<!-- sidebar content -->
</div>
<div class="app-main flex-1 overflow-x-hidden overflow-y-auto">
<div class="app-main-content p-5">
<!-- main content -->
</div>
</div>
</div>
```

**Step 2:** Create a critical styles partial (e.g., `app/views/layouts/_critical_styles.html.erb`) targeting those semantic selectors:

```erb
<%#
Critical CSS for preventing FOUC. Uses semantic selectors so it doesn't
need to change when Tailwind utility classes are added or removed.
Only update when the fundamental layout structure changes.
%>
<style data-critical-styles>
.app-body { background-color: #fff; }
.app-shell { display: flex; flex-direction: row; height: 100vh; width: 100vw; }
.app-sidebar {
display: flex; flex-direction: column; overflow-y: auto; padding: 1.25rem;
background-color: #f1f5f9; border-style: solid;
border-right-width: 2px; border-color: #334155;
min-width: 400px; max-width: 400px;
}
.app-main { flex: 1 1 0%; overflow-x: hidden; overflow-y: auto; }
.app-main-content { padding: 1.25rem; }
</style>
```

**Step 3:** Include it in your layout's `<head>` before the stylesheet:

```erb
<head>
<%= render "layouts/critical_styles" %>
<%= stylesheet_pack_tag('application', media: 'all') %>
</head>
```

**Guidelines for critical CSS:**

- **Use semantic selectors** - `.app-shell`, `.app-sidebar`, etc. instead of mirroring Tailwind class names
- **Keep it minimal** - Only define the layout shell (not component styles)
- **Focus on layout-affecting properties** - `display`, `flex`, `width`, `height`, `position`
- **Include visible defaults** - Background colors and borders that prevent jarring changes
- **Add `data-critical-styles`** - Makes it easy to test that critical styles appear before the bundle

## 🎨 CSS Modules Issues

### "CSS modules returning undefined" (Shakapacker 9+)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<%#
Critical CSS for preventing Flash of Unstyled Content (FOUC)
=============================================================

## What is the problem?

The layout uses Tailwind utility classes (flex, h-screen, bg-slate-100, etc.)
in the HTML. These classes have no effect until the Tailwind CSS bundle
(client-bundle.css) loads from webpack. On slow connections, the page renders
with browser defaults first: single-column, no sidebar, no colors. Then CSS
loads and the layout jumps into place. This is FOUC.

## How does this fix work?

By inlining critical layout CSS using stable semantic selectors (.app-shell,
.app-sidebar, .app-main), the browser can render the correct layout
immediately - before any external CSS loads. This means the sidebar, flex
layout, and background colors render correctly on the very first paint.

The semantic selectors are decoupled from Tailwind class names, so this
partial does not need to change when Tailwind utility classes are added
or removed. It only needs updating when the fundamental layout structure
changes (e.g., sidebar width, layout direction).

Once the full Tailwind CSS bundle loads, it naturally takes over (identical
property values, so no visual change).

## How to reproduce the FOUC (to verify this fix is still needed)

1. Comment out the `render "layouts/critical_styles"` line in application.html.erb
2. Set up the dummy app environment:
cd react_on_rails_pro/spec/dummy
bundle install && pnpm install
RAILS_ENV=development bin/shakapacker # compile webpack assets
bin/rails db:migrate
rails s -p 3000
3. Open Chrome DevTools > Network > Throttle to "Slow 3G"
4. Load http://localhost:3000/ and observe unstyled single-column layout flash
before CSS loads and the proper two-column layout appears.

Alternatively, use Playwright with CDP network throttling:
const client = await context.newCDPSession(page);
await client.send("Network.emulateNetworkConditions", {
offline: false,
downloadThroughput: (20 * 1024) / 8,
uploadThroughput: (20 * 1024) / 8,
latency: 500,
});
await page.goto("http://localhost:3000/", { waitUntil: "commit" });
await page.screenshot({ path: "fouc-check.png" });

## How to maintain this file

This file uses semantic selectors (.app-body, .app-shell, .app-sidebar, etc.)
that are added alongside Tailwind classes in application.html.erb. You only
need to update this file when the fundamental layout structure changes
(e.g., sidebar width, layout direction, major spacing).

The semantic selectors correspond to these elements in application.html.erb:
<body class="app-body ...">
<div class="app-shell ..."> (full-screen flex row container)
<div class="app-sidebar ..."> (fixed-width sidebar)
<div class="app-sidebar-header ..."> (logo + title row)
<div class="app-main ..."> (flexible main content area)
<div class="app-main-content ..."> (padded content wrapper)
%>
<!-- Critical inline styles to prevent FOUC - see _critical_styles.html.erb for docs -->
<style data-critical-styles>
/* App shell: full-screen flex row */
.app-body { background-color: #fff; }
.app-shell { display: flex; flex-direction: row; height: 100vh; width: 100vw; }

/* Sidebar: fixed-width column with background and border */
.app-sidebar {
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 1.25rem;
background-color: #f1f5f9;
border-style: solid;
border-right-width: 2px;
border-color: #334155;
min-width: 400px;
max-width: 400px;
}
.app-sidebar-header { display: flex; flex-direction: row; }

/* Main content: flexible, scrollable area */
.app-main { flex: 1 1 0%; overflow-x: hidden; overflow-y: auto; }
.app-main-content { padding: 1.25rem; }
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<title>Dummy</title>
<% end %>
<%= yield :head %>
<%= render "layouts/critical_styles" %>
<style>
html {
font-size: 16px !important;
Expand All @@ -34,10 +35,10 @@
<% end %>
<%= csrf_meta_tags %>
</head>
<body class="bg-white">
<div class="flex flex-row h-screen w-screen">
<div class="flex flex-col overflow-y-auto p-5 bg-slate-100 border-solid border-r-2 border-slate-700 min-w-[400px] max-w-[400px]">
<div class="flex flex-row">
<body class="app-body bg-white">
<div class="app-shell flex flex-row h-screen w-screen">
<div class="app-sidebar flex flex-col overflow-y-auto p-5 bg-slate-100 border-solid border-r-2 border-slate-700 min-w-[400px] max-w-[400px]">
<div class="app-sidebar-header flex flex-row">
<img style="width: 70px; height: 62px;" src="/rorp_logo.png"/>
<div class="px-2">
<h1 class="page-header">
Expand All @@ -50,8 +51,8 @@
</div>
<%= render "shared/menu" %>
</div>
<div class="flex-1 overflow-x-hidden overflow-y-auto">
<div class="p-5">
<div class="app-main flex-1 overflow-x-hidden overflow-y-auto">
<div class="app-main-content p-5">
<% flash.each do |key, value| %>
<%= content_tag :div, value, class: "flash #{key}" %>
<% end %>
Expand Down
29 changes: 29 additions & 0 deletions react_on_rails_pro/spec/dummy/spec/system/integration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,35 @@ def change_text_expect_dom_selector(dom_selector, expect_no_change: false)
end
end

describe "Critical styles for FOUC prevention", :rack_test do
before { visit root_path }

it "renders critical inline styles in the head" do
html = page.html
critical_pos = html.index("data-critical-styles")
expect(critical_pos).not_to be_nil, "Expected critical styles <style> tag in the HTML"

# Verify critical styles appear in <head> (before <body>)
body_pos = html.index("<body")
expect(body_pos).not_to be_nil, "Expected <body> tag in the HTML"
expect(critical_pos).to be < body_pos,
Comment on lines +39 to +41
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nil comparison in test
If client-bundle.css isn’t present, stylesheet_pos is nil and the test skips, but critical_pos can also be nil (e.g., regression where the partial stops rendering). In that case expect(critical_pos).to be < stylesheet_pos compares nil < Integer / nil < nil and will raise before the skip logic is reached in other branches. Consider asserting critical_pos is present (like the first spec) before any ordering comparisons, and use skip only for the stylesheet position.

Also appears in: react_on_rails_pro/spec/dummy/spec/system/integration_spec.rb:48-56.

"Critical styles must appear in <head> before <body>"
end

it "renders critical inline styles before the stylesheet bundle" do
html = page.html
critical_pos = html.index("data-critical-styles")
expect(critical_pos).not_to be_nil, "Expected critical styles <style> tag in the HTML"

# stylesheet_pack_tag may not emit a link when CSS is inlined via webpack style-loader
stylesheet_pos = html.index("client-bundle.css")
skip "client-bundle.css not found in HTML (CSS may be inlined via style-loader)" unless stylesheet_pos

expect(critical_pos).to be < stylesheet_pos,
"Critical styles must appear before the stylesheet bundle to prevent FOUC"
end
end

# Basic ReactOnRails specs
describe "Pages/Index", :js do
subject { page }
Expand Down
Loading