Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/delete-changeset-bot-comments.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: 🗑️ Delete Changeset Bot Comments

on:
workflow_dispatch:

jobs:
delete-comments:
name: 🗑️ Delete Changeset Bot Comments
if: github.repository == 'remix-run/react-router'
runs-on: ubuntu-latest
permissions:
pull-requests: write

steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v6

- name: 📦 Setup pnpm
uses: pnpm/action-setup@v4

- name: ⎔ Setup node
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: pnpm

- name: 📥 Install deps
run: pnpm install --frozen-lockfile

- name: 🗑️ Delete changeset-bot comments
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
node scripts/changes/delete-changeset-bot-comments.ts
60 changes: 57 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
# We use this singular file for all of our releases because we can only specify
# a singular GitHub workflow file in npm's Trusted Publishing configuration.
# See https://docs.npmjs.com/trusted-publishers for more info.
#
# Specific jobs only run on the proper trigger:
#
# - Change file driven stable releases (push to release/hotfix branches)
# - Experimental releases (from a workflow_dispatch trigger)

name: 🚢 Release/Publish

on:
push:
branches:
- "release"
- "hotfix"
workflow_dispatch:
inputs:
branch:
required: true

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand All @@ -13,7 +26,7 @@ concurrency:
jobs:
check:
name: Check release readiness
if: github.repository == 'remix-run/react-router'
if: github.repository == 'remix-run/react-router' && github.event_name == 'push'
runs-on: ubuntu-latest
outputs:
has_change_files: ${{ steps.check.outputs.has_change_files }}
Expand All @@ -38,7 +51,7 @@ jobs:
pull_request:
name: Open pull request
needs: check
if: needs.check.outputs.has_change_files == 'true'
if: github.event_name == 'push' && needs.check.outputs.has_change_files == 'true'
runs-on: ubuntu-latest
permissions:
contents: write # enable pushing changes to the origin
Expand Down Expand Up @@ -69,7 +82,7 @@ jobs:
publish:
name: Publish
needs: check
if: needs.check.outputs.has_change_files == 'false'
if: github.event_name == 'push' && needs.check.outputs.has_change_files == 'false'
runs-on: ubuntu-latest
permissions:
contents: write # enable pushing changes to the origin
Expand Down Expand Up @@ -97,3 +110,44 @@ jobs:
run: pnpm changes:publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

experimental-release:
name: 🧪 Experimental Release
if: github.repository == 'remix-run/react-router' && github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: write # enable pushing changes to the origin
id-token: write # enable generation of an ID token for publishing
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v6
with:
ref: ${{ github.event.inputs.branch }}
# checkout using a custom token so that we can push later on
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0

- name: 📦 Setup pnpm
uses: pnpm/action-setup@v4

- name: ⎔ Setup node
uses: actions/setup-node@v6
with:
node-version: 24 # Needed for npm@11 for Trusted Publishing
cache: "pnpm"

- name: 📥 Install deps
run: pnpm install --frozen-lockfile

- name: ⤴️ Update version
run: |
git config --local user.email "hello@remix.run"
git config --local user.name "Remix Run Bot"
pnpm run experimental:version
git push origin --tags

- name: 🏗 Build
run: pnpm build

- name: 🚀 Publish
run: pnpm run experimental:publish
4 changes: 2 additions & 2 deletions GOVERNANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ This table gives a high-level overview of the stages, but please see the individ

- A proposal enters **Stage 3 — Beta** once it receives **Stage 2 — Alpha** PR approvals from 2 SC members and is merged to `dev`
- An SC member authoring the `unstable_` PR counts as an implicit approval, so in those cases explicit approval is required from 1 additional SC member
- This will include the feature in `nightly` releases and the next normal SemVer release for broader beta testing under the `unstable_` flag
- This will include the feature in the next normal SemVer release for broader beta testing behind the `unstable_` flag

### Stage 4 — Stabilization

Expand All @@ -151,7 +151,7 @@ This table gives a high-level overview of the stages, but please see the individ

- A proposal enters **Stage 5 — Stable** once it receives **Stage 4 — Stabilization** PR approvals from at least 50% of the SC members and is merged to `dev`
- An SC member authoring the stabilization PR counts as an implicit approval
- This will include the stable feature in `nightly` releases and the next normal SemVer release
- This will include the stable feature in the next normal SemVer release

## Meeting Notes

Expand Down
87 changes: 87 additions & 0 deletions docs/explanation/styling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
title: Styling
---

# Styling

[MODES: framework]

<br/>
<br/>

Framework mode uses the React Router Vite plugin, so the styling story is mostly just Vite's styling story.

React Router does not have a separate CSS pipeline for Framework mode. In practice, there are three patterns that matter:

1. Import CSS as a side effect
2. Use the route module `links` export
3. Render a stylesheet `<link>` directly

## Side-Effect CSS Imports

Because Framework mode uses Vite, you can import CSS files as side effects:

```tsx filename=app/root.tsx
import "./app.css";
```

```tsx filename=app/routes/dashboard.tsx
import "./dashboard.css";
```

This is often the simplest option. Global styles can be imported in `root.tsx`, and route or component styles can be imported next to the module that uses them.

## `links` Export

React Router also supports adding stylesheets through the route module `links` export.

This is useful when you want a stylesheet URL from Vite and need React Router to render a real `<link rel="stylesheet">` tag for the route:

```tsx filename=app/routes/dashboard.tsx
import dashboardHref from "./dashboard.css?url";

export function links() {
return [{ rel: "stylesheet", href: dashboardHref }];
}
```

The `links` export feeds the [`<Links />`][links-component] component in your root route. This is the React Router-specific styling API in Framework mode. For more on route module exports, see [Route Module][route-module].

## Direct `<link>` Rendering

If you're using React 19, you can also render a stylesheet `<link>` directly in your route component:

```tsx filename=app/routes/dashboard.tsx
import dashboardHref from "./dashboard.css?url";

export default function Dashboard() {
return (
<>
<link
rel="stylesheet"
href={dashboardHref}
precedence="default"
/>
<h1>Dashboard</h1>
</>
);
}
```

This uses React's built-in [`<link>`][react-link] support, which hoists the stylesheet into the document `<head>`. That gives you another way to colocate stylesheet tags with the route that needs them.

## Everything Else

For CSS Modules, Tailwind, PostCSS, Sass, Vanilla Extract, and other styling tools, use the normal Vite setup for those tools.

See:

- [Vite CSS Features][vite-css]
- [Vite Static Asset Handling][vite-assets]
- [`<Links />`][links-component]

[links-component]: ../api/components/Links
[react-link]: https://react.dev/reference/react-dom/components/link
[route-module]: ../start/framework/route-module
[vite-assets]: https://vite.dev/guide/assets.html
[vite-css]: https://vite.dev/guide/features.html#css
5 changes: 5 additions & 0 deletions docs/start/framework/route-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,10 @@ export default function Root() {
}
```

See also:

- [Styling][styling]

## `meta`

Route meta defines [meta tags][meta-element] to be rendered in the `<Meta />` component, usually placed in the `<head>`.
Expand Down Expand Up @@ -520,3 +524,4 @@ Next: [Rendering Strategies](./rendering)
[data-mode-should-revalidate]: ../data/route-object#shouldrevalidate
[spa-mode]: ../../how-to/spa
[client-data]: ../../how-to/client-data
[styling]: ../../explanation/styling
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"docs": "pnpm run docs:typedoc && pnpm run docs:jsdoc",
"docs:typedoc": "typedoc",
"docs:jsdoc": "node --experimental-strip-types scripts/docs.ts",
"experimental:version": "node ./scripts/experimental.ts version",
"experimental:publish": "node ./scripts/experimental.ts publish",
"format": "prettier --ignore-path .prettierignore --write .",
"format:check": "prettier --ignore-path .prettierignore --check .",
"lint": "eslint --cache .",
Expand Down
4 changes: 2 additions & 2 deletions scripts/changes/check-pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ import {
const COMMENT_MARKER = "<!-- change-file-check -->";

const COMMENT_FOUND = `${COMMENT_MARKER}
## ✅ Change File Found
### ✅ Change File Found

A \`.changes\` file has been found in this PR. Thanks!`;

const COMMENT_MISSING = `${COMMENT_MARKER}
## 📝 No Change File Found
### 📝 No Change File Found

This PR doesn't include a change file which is used for automated release notes.
If your change affects users, please add one (or more) and commit the generated file(s).
Expand Down
89 changes: 89 additions & 0 deletions scripts/changes/delete-changeset-bot-comments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Finds and deletes comments from `changeset-bot` on open PRs created since 1/1/2026
*
* Usage:
* node scripts/changes/delete-changeset-bot-comments.ts [--dry-run]
*
* Environment:
* GITHUB_TOKEN - Required. GitHub token with pull-requests:write permission.
*/
import {
createPrComment,
deletePrComment,
getPrComments,
listOpenPrs,
} from "../utils/github.ts";

const CHANGESET_BOT = "changeset-bot[bot]";
const CUTOFF = new Date(2026, 0, 1);

const ADD_CHANGE_FILE =
"👋 We've moved away from Changesets to our own internal " +
"[changes process](https://github.com/remix-run/react-router/blob/main/docs/community/contributing.md#change-files). " +
"Please manually add a change file to this branch, or you can merge in the " +
"latest `dev` branch and run `pnpm run changes:add` to add a change file.";

const MIGRATE_CHANGE_FILE =
"👋 We've moved away from Changesets to our own internal " +
"[changes process](https://github.com/remix-run/react-router/blob/main/docs/community/contributing.md#change-files). " +
"Please convert your changesets file to a change file in the proper package directory " +
"(i.e., `packages/react-router/.changes/patch.fix-some-bug.md`).";

const dryRun = process.argv.includes("--dry-run");

if (dryRun) {
console.log("[DRY RUN] No comments will be deleted.\n");
}

console.log(
`Fetching open PRs created after ${CUTOFF.toISOString().slice(0, 10)}...\n`,
);

let prs = await listOpenPrs({
createdAfter: CUTOFF,
base: "dev",
});
console.log(`Found ${prs.length} open PR${prs.length === 1 ? "" : "s"}.\n`);

let totalDeleted = 0;
let totalSkipped = 0;

for (let pr of prs) {
let comments = await getPrComments(pr.number);
let botComments = comments.filter((c) => c.user?.login === CHANGESET_BOT);

if (botComments.length === 0) continue;

console.log(`PR #${pr.number}: ${pr.title}`);

for (let comment of botComments) {
let preview = (comment.body ?? "").slice(0, 80).replace(/\n/g, " ");
if (dryRun) {
console.log(
` [DRY RUN] Would delete comment #${comment.id}: "${preview}"`,
);
totalSkipped++;
} else {
let hasChangeFile = comment.body?.includes("Changeset detected");
await deletePrComment(comment.id);
await createPrComment(
pr.number,
hasChangeFile ? MIGRATE_CHANGE_FILE : ADD_CHANGE_FILE,
);
console.log(` Deleted comment #${comment.id}: "${preview}"`);
totalDeleted++;
}
}

console.log();
}

if (dryRun) {
console.log(
`Done (dry run): ${totalSkipped} comment${totalSkipped === 1 ? "" : "s"} would be deleted.`,
);
} else {
console.log(
`Done: ${totalDeleted} comment${totalDeleted === 1 ? "" : "s"} deleted.`,
);
}
Loading
Loading