Skip to content

Commit b4dcc77

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/4238-rsc-compression
* origin/main: Generate Tailwind as a layout-owned pack (#4182) [Pro] Test rejected RSC replacement retry notifications (#4250)
2 parents 081aebd + 1b843e0 commit b4dcc77

29 files changed

Lines changed: 2173 additions & 236 deletions

.lychee.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ exclude = [
8888
'^https://vite-ruby\.netlify\.app/?$', # Intermittent connection resets from CI
8989
'^https://www\.developerway\.com/posts/react-server-components-performance$', # Returns 503 from CI
9090
'^https://webaim\.org/techniques/css/invisiblecontent/?$', # Returns 504 from CI
91+
'^https://compiledcssinjs\.com/?$', # Times out during automated link checks
92+
'^https://vanilla-extract\.style/?$', # Times out during automated link checks
9193

9294
# ============================================================================
9395
# PLANNED DEPLOYMENTS NOT YET LIVE

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ
6060
[PR 4249](https://github.com/shakacode/react_on_rails/pull/4249) by
6161
[justin808](https://github.com/justin808).
6262

63+
- **Generated Tailwind apps load Tailwind from the layout.** The install generator now declares Tailwind through a layout-owned pack instead of component-owned imports, keeps generated layout head metadata mobile/CSP-ready, and warns safely when custom layouts need manual pack-tag replacement. [PR 4182](https://github.com/shakacode/react_on_rails/pull/4182) by [ihabadham](https://github.com/ihabadham).
64+
6365
- **[Pro]** **Rspack RSC dev-server setup is easier to diagnose and customize**:
6466
Generated RSC helper code now verifies client-reference discovery support
6567
through the sibling `rscWebpackConfig.js` file instead of assuming

docs/oss/building-features/styling-with-tailwind.md

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -39,29 +39,41 @@ It also creates `app/javascript/stylesheets/application.css`:
3939

4040
<!-- prettier-ignore -->
4141
```css
42-
@import "tailwindcss";
42+
@import "tailwindcss" source("../..");
4343
```
4444

45-
The generated `HelloWorld.client.jsx` or `HelloWorld.client.tsx` imports that CSS
46-
file and uses Tailwind utility classes. The shared `commonWebpackConfig.js`
47-
template inserts `postcss-loader` after `css-loader` and configures
48-
`@tailwindcss/postcss`, regardless of whether the project uses Webpack or Rspack.
45+
The generator also creates `app/javascript/packs/react_on_rails_tailwind.js`
46+
to import the Tailwind stylesheet and declares that pack from
47+
`app/views/layouts/react_on_rails_default.html.erb`:
48+
49+
```erb
50+
<% prepend_javascript_pack_tag "react_on_rails_tailwind" %>
51+
<%= stylesheet_pack_tag "react_on_rails_tailwind", media: "all" %>
52+
<%= javascript_pack_tag %>
53+
```
54+
55+
The generated `HelloWorld.client.jsx` or `HelloWorld.client.tsx` uses Tailwind
56+
utility classes but does not import the global stylesheet. The shared
57+
`commonWebpackConfig.js` template inserts `postcss-loader` after `css-loader`
58+
and configures `@tailwindcss/postcss`, regardless of whether the project uses
59+
Webpack or Rspack.
4960

5061
## Server Rendering Without a Flash of Unstyled Content
5162

52-
The generated example keeps two pieces together:
63+
The generated example keeps three pieces together:
5364

5465
1. `app/views/hello_world/index.html.erb` renders the component with
5566
`prerender: true`.
56-
2. `app/views/layouts/react_on_rails_default.html.erb` keeps
57-
`<%= stylesheet_pack_tag %>` in the document head before
67+
2. `app/javascript/packs/react_on_rails_tailwind.js` imports the Tailwind
68+
stylesheet as an app-level pack.
69+
3. `app/views/layouts/react_on_rails_default.html.erb` keeps
70+
`<%= stylesheet_pack_tag "react_on_rails_tailwind", media: "all" %>` before
5871
`<%= javascript_pack_tag %>`.
5972

60-
When React on Rails auto-loads the generated component pack, it appends the
61-
generated component stylesheet pack. In a production build, Shakapacker emits
62-
that stylesheet as an extracted CSS `<link>` in the SSR HTML. Do not remove the
63-
empty `stylesheet_pack_tag`; it is the placeholder React on Rails uses to insert
64-
component CSS and avoid a Tailwind flash of unstyled content.
73+
Tailwind is layout-owned because it is global app styling. The generated
74+
components only reference Tailwind class names. Do not move the Tailwind CSS
75+
import back into a generated component; pages that use Tailwind classes but do
76+
not load that component would then depend on the wrong pack for global styles.
6577

6678
In development, CSS can be injected by the dev server instead of extracted as a
6779
static link. Use a production build when validating the no-FOUC path.
@@ -76,8 +88,9 @@ npm install tailwindcss@^4.3.0 @tailwindcss/postcss@^4.3.0 postcss@^8.5.15 postc
7688
yarn add tailwindcss@^4.3.0 @tailwindcss/postcss@^4.3.0 postcss@^8.5.15 postcss-loader@^8.2.1
7789
```
7890

79-
Then create a CSS entry, import it from your client component or client pack,
80-
and add `postcss-loader` after `css-loader` in your shared bundler config:
91+
Then create a CSS entry, import it from an app-level client pack that your
92+
layout declares, and add `postcss-loader` after `css-loader` in your shared
93+
bundler config:
8194

8295
```javascript
8396
{
@@ -90,8 +103,9 @@ and add `postcss-loader` after `css-loader` in your shared bundler config:
90103
}
91104
```
92105

93-
Keep the SSR view prerendered and keep `stylesheet_pack_tag` in the layout head
94-
so the generated component CSS can be emitted before the JavaScript runs.
106+
Keep the SSR view prerendered and keep the Tailwind stylesheet pack tag in the
107+
layout head so the app-level Tailwind CSS can be emitted before the JavaScript
108+
runs when Shakapacker is configured to emit stylesheet links.
95109

96110
Tailwind v3 projects should use Tailwind's v3 PostCSS setup instead of this v4
97111
recipe.

docs/pro/react-server-components/css-and-styling.md

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -302,36 +302,46 @@ three-bundle architecture.
302302

303303
**How Tailwind works with RSC:**
304304

305-
1. Tailwind runs as a PostCSS plugin during the client bundle build only.
306-
2. It scans all files listed in its `content` configuration for utility class names.
307-
3. The generated CSS is extracted into the client stylesheet.
305+
1. Tailwind/PostCSS follows each bundle's CSS loader pipeline when CSS imports are processed, but
306+
only the client bundle emits browser-loadable CSS.
307+
2. Tailwind scans configured source roots for utility class names. Tailwind v4 uses CSS `@source`
308+
directives; Tailwind v3 uses the `content` array.
309+
3. The generated CSS is delivered through a layout-owned client pack.
308310
4. Server Components and Client Components both use Tailwind class names as plain strings.
309-
5. The CSS loads from the Rails layout's `stylesheet_pack_tag`.
311+
5. The CSS loads according to the Rails layout's Shakapacker pack tags.
310312

311-
**Critical configuration:** The Tailwind `content` array must include all directories that contain
312-
files using Tailwind classes, including React component files and ERB views:
313+
**Critical configuration:** Tailwind must scan every directory that contains utility class names,
314+
including React component files and ERB views. Tailwind v4 configures those roots from CSS
315+
`@source` directives; Tailwind v3 uses the `content` array.
313316

314317
#### Tailwind CSS v4 (new apps)
315318

316319
The React on Rails generator supports Tailwind v4 via `--tailwind`. Tailwind v4 uses a CSS-first
317320
configuration model:
318321

322+
<!-- prettier-ignore -->
319323
```css
320-
/* app/javascript/styles/application.css */
321-
@import 'tailwindcss';
324+
/* app/javascript/stylesheets/application.css */
325+
@import "tailwindcss" source("../..");
322326
```
323327

324328
```js
325-
// postcss.config.mjs
326-
export default {
327-
plugins: {
328-
'@tailwindcss/postcss': {},
329-
},
330-
};
329+
// app/javascript/packs/react_on_rails_tailwind.js
330+
import '../stylesheets/application.css';
331+
```
332+
333+
The generated React on Rails layout declares that pack from the layout:
334+
335+
```erb
336+
<% prepend_javascript_pack_tag "react_on_rails_tailwind" %>
337+
<%= stylesheet_pack_tag "react_on_rails_tailwind", media: "all" %>
338+
<%= javascript_pack_tag %>
331339
```
332340

333-
Tailwind v4 auto-discovers source files without a `content` configuration. It scans the project
334-
tree automatically.
341+
Tailwind v4 uses CSS-level source discovery. The generated stylesheet points at the Rails `app/`
342+
directory by default so Tailwind scans app source without scanning the whole repository, build
343+
output, logs, or runtime directories. If your components live outside that source tree, add
344+
additional Tailwind `@source` lines to the stylesheet.
335345

336346
#### Tailwind CSS v3 (existing apps)
337347

llms-full-pro.txt

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5680,36 +5680,46 @@ three-bundle architecture.
56805680

56815681
**How Tailwind works with RSC:**
56825682

5683-
1. Tailwind runs as a PostCSS plugin during the client bundle build only.
5684-
2. It scans all files listed in its `content` configuration for utility class names.
5685-
3. The generated CSS is extracted into the client stylesheet.
5683+
1. Tailwind/PostCSS follows each bundle's CSS loader pipeline when CSS imports are processed, but
5684+
only the client bundle emits browser-loadable CSS.
5685+
2. Tailwind scans configured source roots for utility class names. Tailwind v4 uses CSS `@source`
5686+
directives; Tailwind v3 uses the `content` array.
5687+
3. The generated CSS is delivered through a layout-owned client pack.
56865688
4. Server Components and Client Components both use Tailwind class names as plain strings.
5687-
5. The CSS loads from the Rails layout's `stylesheet_pack_tag`.
5689+
5. The CSS loads according to the Rails layout's Shakapacker pack tags.
56885690

5689-
**Critical configuration:** The Tailwind `content` array must include all directories that contain
5690-
files using Tailwind classes, including React component files and ERB views:
5691+
**Critical configuration:** Tailwind must scan every directory that contains utility class names,
5692+
including React component files and ERB views. Tailwind v4 configures those roots from CSS
5693+
`@source` directives; Tailwind v3 uses the `content` array.
56915694

56925695
#### Tailwind CSS v4 (new apps)
56935696

56945697
The React on Rails generator supports Tailwind v4 via `--tailwind`. Tailwind v4 uses a CSS-first
56955698
configuration model:
56965699

5700+
<!-- prettier-ignore -->
56975701
```css
5698-
/* app/javascript/styles/application.css */
5699-
@import 'tailwindcss';
5702+
/* app/javascript/stylesheets/application.css */
5703+
@import "tailwindcss" source("../..");
57005704
```
57015705

57025706
```js
5703-
// postcss.config.mjs
5704-
export default {
5705-
plugins: {
5706-
'@tailwindcss/postcss': {},
5707-
},
5708-
};
5707+
// app/javascript/packs/react_on_rails_tailwind.js
5708+
import '../stylesheets/application.css';
5709+
```
5710+
5711+
The generated React on Rails layout declares that pack from the layout:
5712+
5713+
```erb
5714+
<% prepend_javascript_pack_tag "react_on_rails_tailwind" %>
5715+
<%= stylesheet_pack_tag "react_on_rails_tailwind", media: "all" %>
5716+
<%= javascript_pack_tag %>
57095717
```
57105718

5711-
Tailwind v4 auto-discovers source files without a `content` configuration. It scans the project
5712-
tree automatically.
5719+
Tailwind v4 uses CSS-level source discovery. The generated stylesheet points at the Rails `app/`
5720+
directory by default so Tailwind scans app source without scanning the whole repository, build
5721+
output, logs, or runtime directories. If your components live outside that source tree, add
5722+
additional Tailwind `@source` lines to the stylesheet.
57135723

57145724
#### Tailwind CSS v3 (existing apps)
57155725

llms-full.txt

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9020,29 +9020,41 @@ It also creates `app/javascript/stylesheets/application.css`:
90209020

90219021
<!-- prettier-ignore -->
90229022
```css
9023-
@import "tailwindcss";
9023+
@import "tailwindcss" source("../..");
90249024
```
90259025

9026-
The generated `HelloWorld.client.jsx` or `HelloWorld.client.tsx` imports that CSS
9027-
file and uses Tailwind utility classes. The shared `commonWebpackConfig.js`
9028-
template inserts `postcss-loader` after `css-loader` and configures
9029-
`@tailwindcss/postcss`, regardless of whether the project uses Webpack or Rspack.
9026+
The generator also creates `app/javascript/packs/react_on_rails_tailwind.js`
9027+
to import the Tailwind stylesheet and declares that pack from
9028+
`app/views/layouts/react_on_rails_default.html.erb`:
9029+
9030+
```erb
9031+
<% prepend_javascript_pack_tag "react_on_rails_tailwind" %>
9032+
<%= stylesheet_pack_tag "react_on_rails_tailwind", media: "all" %>
9033+
<%= javascript_pack_tag %>
9034+
```
9035+
9036+
The generated `HelloWorld.client.jsx` or `HelloWorld.client.tsx` uses Tailwind
9037+
utility classes but does not import the global stylesheet. The shared
9038+
`commonWebpackConfig.js` template inserts `postcss-loader` after `css-loader`
9039+
and configures `@tailwindcss/postcss`, regardless of whether the project uses
9040+
Webpack or Rspack.
90309041

90319042
## Server Rendering Without a Flash of Unstyled Content
90329043

9033-
The generated example keeps two pieces together:
9044+
The generated example keeps three pieces together:
90349045

90359046
1. `app/views/hello_world/index.html.erb` renders the component with
90369047
`prerender: true`.
9037-
2. `app/views/layouts/react_on_rails_default.html.erb` keeps
9038-
`<%= stylesheet_pack_tag %>` in the document head before
9048+
2. `app/javascript/packs/react_on_rails_tailwind.js` imports the Tailwind
9049+
stylesheet as an app-level pack.
9050+
3. `app/views/layouts/react_on_rails_default.html.erb` keeps
9051+
`<%= stylesheet_pack_tag "react_on_rails_tailwind", media: "all" %>` before
90399052
`<%= javascript_pack_tag %>`.
90409053

9041-
When React on Rails auto-loads the generated component pack, it appends the
9042-
generated component stylesheet pack. In a production build, Shakapacker emits
9043-
that stylesheet as an extracted CSS `<link>` in the SSR HTML. Do not remove the
9044-
empty `stylesheet_pack_tag`; it is the placeholder React on Rails uses to insert
9045-
component CSS and avoid a Tailwind flash of unstyled content.
9054+
Tailwind is layout-owned because it is global app styling. The generated
9055+
components only reference Tailwind class names. Do not move the Tailwind CSS
9056+
import back into a generated component; pages that use Tailwind classes but do
9057+
not load that component would then depend on the wrong pack for global styles.
90469058

90479059
In development, CSS can be injected by the dev server instead of extracted as a
90489060
static link. Use a production build when validating the no-FOUC path.
@@ -9057,8 +9069,9 @@ npm install tailwindcss@^4.3.0 @tailwindcss/postcss@^4.3.0 postcss@^8.5.15 postc
90579069
yarn add tailwindcss@^4.3.0 @tailwindcss/postcss@^4.3.0 postcss@^8.5.15 postcss-loader@^8.2.1
90589070
```
90599071

9060-
Then create a CSS entry, import it from your client component or client pack,
9061-
and add `postcss-loader` after `css-loader` in your shared bundler config:
9072+
Then create a CSS entry, import it from an app-level client pack that your
9073+
layout declares, and add `postcss-loader` after `css-loader` in your shared
9074+
bundler config:
90629075

90639076
```javascript
90649077
{
@@ -9071,8 +9084,9 @@ and add `postcss-loader` after `css-loader` in your shared bundler config:
90719084
}
90729085
```
90739086

9074-
Keep the SSR view prerendered and keep `stylesheet_pack_tag` in the layout head
9075-
so the generated component CSS can be emitted before the JavaScript runs.
9087+
Keep the SSR view prerendered and keep the Tailwind stylesheet pack tag in the
9088+
layout head so the app-level Tailwind CSS can be emitted before the JavaScript
9089+
runs when Shakapacker is configured to emit stylesheet links.
90769090

90779091
Tailwind v3 projects should use Tailwind's v3 PostCSS setup instead of this v4
90789092
recipe.

packages/react-on-rails-pro/tests/boundedCacheProvider.client.test.tsx

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1467,4 +1467,93 @@ describe('RSCRoute successful-version error reset', () => {
14671467
const key = createRSCPayloadKey('Card', { id: 0 });
14681468
await waitFor(() => expect(rscApi.successfulVersions[key]).toBeGreaterThan(0));
14691469
});
1470+
1471+
it('p. rejected replacement loads evict their promise and restore the evicted-success marker', async () => {
1472+
type PendingEntry = Deferred & { args: GetServerComponentArgs };
1473+
const pending: PendingEntry[] = [];
1474+
getServerComponent = jest.fn((..._args: [GetServerComponentArgs]) => {
1475+
const d = makeDeferred();
1476+
pending.push({ ...d, args: _args[0] });
1477+
return d.promise;
1478+
});
1479+
RSCProvider = createRSCProvider({ getServerComponent });
1480+
1481+
let rscApi!: ReturnType<typeof useRSC>;
1482+
const Probe = () => {
1483+
rscApi = useRSC();
1484+
return null;
1485+
};
1486+
await renderInAct(
1487+
<RSCProvider>
1488+
<Probe />
1489+
</RSCProvider>,
1490+
);
1491+
1492+
const startLoad = async (id: number) => {
1493+
let promise!: Promise<React.ReactNode>;
1494+
await act(async () => {
1495+
promise = rscApi.getComponent('Card', { id });
1496+
await Promise.resolve();
1497+
});
1498+
return { promise, deferred: findPendingForId(pending, id) };
1499+
};
1500+
1501+
const resolveLoad = async (started: Awaited<ReturnType<typeof startLoad>>, payload: React.ReactNode) => {
1502+
await act(async () => {
1503+
started.deferred.resolve(payload);
1504+
await started.promise;
1505+
// Unlike peers k-o, flush the timer-scheduled unpin after each resolve;
1506+
// otherwise the fill loop keeps every entry pinned and never records
1507+
// the evicted-success marker that the retry assertions depend on.
1508+
await flushMacrotasks();
1509+
});
1510+
};
1511+
1512+
const rejectLoad = async (started: Awaited<ReturnType<typeof startLoad>>, message: string) => {
1513+
void started.promise.catch(() => undefined);
1514+
await act(async () => {
1515+
started.deferred.reject(new Error(message));
1516+
await expect(started.promise).rejects.toThrow(message);
1517+
// evictPromiseIfRejected removes the cached promise via setTimeout(0),
1518+
// so wait one macrotask before asserting the slot is free.
1519+
await flushMacrotasks();
1520+
});
1521+
};
1522+
1523+
// Give ids 0..CACHE_CAP a successful payload. Loading the (cap+1)-th key
1524+
// evicts id 0 and records its "last successful payload was evicted" marker.
1525+
for (let id = 0; id <= CACHE_CAP; id += 1) {
1526+
// eslint-disable-next-line no-await-in-loop
1527+
const started = await startLoad(id);
1528+
// eslint-disable-next-line no-await-in-loop
1529+
await resolveLoad(started, <span>{`initial ${id}`}</span>);
1530+
}
1531+
1532+
const key = createRSCPayloadKey('Card', { id: 0 });
1533+
expect(rscApi.successfulVersions[key] ?? 0).toBe(0);
1534+
1535+
// Start id 0's replacement load while its evicted-success marker is
1536+
// present, then churn enough other successful evictions to push that marker
1537+
// out of the bounded marker LRU before the replacement rejects. The retry
1538+
// below only notifies routes if the rejection path restores the marker from
1539+
// the in-flight latch.
1540+
const failedReplacement = await startLoad(0);
1541+
for (let id = CACHE_CAP + 1; id <= CACHE_CAP + MARKER_CAP + 2; id += 1) {
1542+
// eslint-disable-next-line no-await-in-loop
1543+
const started = await startLoad(id);
1544+
// eslint-disable-next-line no-await-in-loop
1545+
await resolveLoad(started, <span>{`marker churn ${id}`}</span>);
1546+
}
1547+
1548+
await rejectLoad(failedReplacement, 'replacement boom 0');
1549+
1550+
expect(fetchCount(0)).toBe(2);
1551+
expect(rscApi.successfulVersions[key] ?? 0).toBe(0);
1552+
1553+
const retryReplacement = await startLoad(0);
1554+
expect(fetchCount(0)).toBe(3);
1555+
await resolveLoad(retryReplacement, <span>replacement after rejection</span>);
1556+
1557+
await waitFor(() => expect(rscApi.successfulVersions[key]).toBeGreaterThan(0));
1558+
});
14701559
});

0 commit comments

Comments
 (0)