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
61 changes: 61 additions & 0 deletions .changeset/friendly-tacos-compile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
'@astrojs/react': major
---

Migrates the React integration to `@vitejs/plugin-react` v6, using Oxc for JSX and Fast Refresh. Removes the `babel` integration option; projects with custom Babel transforms must configure `@rolldown/plugin-babel` under `vite.plugins` instead.

Adds an opt-in `compiler` option for the Oxc-based React Compiler. Install `oxc-transform-react` and enable it in your Astro config:

```js
import react from '@astrojs/react';

export default {
integrations: [react({ compiler: true })],
};
```

The compiler memoizes client components and hooks. Server rendering is not compiled. The target defaults to the installed React major version; React 17 and 18 projects also need `react-compiler-runtime` installed. Pass an options object to configure the compiler, for example `compiler: { compilationMode: 'annotation' }`. The integration's `include` and `exclude` options apply, and dependencies and Astro files are excluded.

## Migrating custom Babel transforms

The integration uses `@vitejs/plugin-react` v6 and Oxc for JSX and Fast Refresh. The `babel` integration option has been removed in this major release.

If you use custom Babel transforms, install `@rolldown/plugin-babel` and `@babel/core`, keeping your existing Babel plugins and presets installed:

```sh
pnpm add -D @rolldown/plugin-babel @babel/core
```

Move your Babel plugins and presets from `react({ babel: ... })` to `babel()` in `vite.plugins`. For example, migrate a project using `babel-plugin-styled-components` from:

```js
import react from '@astrojs/react';
import { defineConfig } from 'astro/config';

export default defineConfig({
integrations: [react({ babel: { plugins: ['babel-plugin-styled-components'] } })],
});
```

To:

```js
import react from '@astrojs/react';
import babel from '@rolldown/plugin-babel';
import { defineConfig } from 'astro/config';

export default defineConfig({
integrations: [react()],
vite: {
plugins: [
babel({
plugins: ['babel-plugin-styled-components'],
}),
],
},
});
```

This also works with `react({ compiler: true })`. Babel runs before Oxc transforms TypeScript and JSX. The Babel plugin automatically enables parsing for `.jsx`, `.ts`, and `.tsx` files. It does not load `babel.config.js` or `.babelrc` files; pass the options directly to `babel()`. The old `babel` callback is not supported; use the plugin's [`overrides` and preset hooks](https://github.com/rolldown/plugins/tree/main/packages/babel#options) for conditional transforms.

Do not enable `babel-plugin-react-compiler` on the same components as the Oxc compiler.
4 changes: 4 additions & 0 deletions packages/astro/e2e/fixtures/react-compiler/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import react from '@astrojs/react';
import { defineConfig } from 'astro/config';

export default defineConfig({ integrations: [react({ compiler: true })] });
13 changes: 13 additions & 0 deletions packages/astro/e2e/fixtures/react-compiler/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "@e2e/react-compiler",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/react": "workspace:*",
"astro": "workspace:*",
"oxc-transform-react": "^0.145.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-compiler-runtime": "^1.0.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { useState } from 'react';

export default function Counter({ label }) {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{label}: {count}</button>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
import Counter from '../components/Counter.jsx';
---
<Counter client:load label="Count" />

<script>
const status: string = "ready";
document.documentElement.dataset.status = status;
</script>
47 changes: 47 additions & 0 deletions packages/astro/e2e/react-compiler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { expect } from '@playwright/test';
import { testFactory, waitForHydrate, type DevServer, type PreviewServer } from './test-utils.ts';

const test = testFactory(import.meta.url, { root: './fixtures/react-compiler/' });

test.describe('React Compiler dev', () => {
let server: DevServer;
test.beforeAll(async ({ astro }) => {
server = await astro.startDevServer();
});
test.afterAll(async () => {
await server?.stop();
});
test('hydrates and preserves state through Fast Refresh', async ({ astro, page }) => {
await page.goto(astro.resolveUrl('/'));
await expect(page.locator('html')).toHaveAttribute('data-status', 'ready');
const button = page.getByRole('button');
await waitForHydrate(page, button);
await button.click();
await expect(button).toHaveText('Count: 1');
await astro.editFile('./src/components/Counter.jsx', (source) =>
source.replace('{label}:', '{label} updated:'),
);
await expect(button).toHaveText('Count updated: 1');
await button.click();
await expect(button).toHaveText('Count updated: 2');
});
});

test.describe('React Compiler build', () => {
let server: PreviewServer;
test.beforeAll(async ({ astro }) => {
await astro.build();
server = await astro.preview();
});
test.afterAll(async () => {
await server?.stop();
});
test('hydrates the compiled production island', async ({ astro, page }) => {
await page.goto(astro.resolveUrl('/'));
await expect(page.locator('html')).toHaveAttribute('data-status', 'ready');
const button = page.getByRole('button');
await waitForHydrate(page, button);
await button.click();
await expect(button).toHaveText('Count: 1');
});
});
67 changes: 67 additions & 0 deletions packages/integrations/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,73 @@ This **[Astro integration][astro-integration]** enables server-side rendering an

Read the [`@astrojs/react` docs][docs]

## React Compiler

Install the optional Oxc compiler:

```sh
pnpm add -D oxc-transform-react
```

Enable it in `astro.config.mjs`:

```js
import react from '@astrojs/react';
import { defineConfig } from 'astro/config';

export default defineConfig({
integrations: [react({ compiler: true })],
});
```

The compiler automatically memoizes client components and hooks. It does not compile server rendering. The target defaults to your installed React major version. For React 17 or 18, also install `react-compiler-runtime` as a runtime dependency.

Pass an options object, such as `compiler: { compilationMode: 'annotation' }`, to configure Oxc's React Compiler. The integration's `include` and `exclude` filters apply; dependencies and Astro files are excluded. Compilation is disabled by default.

## Migrating custom Babel transforms

The integration uses `@vitejs/plugin-react` v6 and Oxc for JSX and Fast Refresh. The `babel` integration option has been removed in this major release.

If you use custom Babel transforms, install `@rolldown/plugin-babel` and `@babel/core`, keeping your existing Babel plugins and presets installed:

```sh
pnpm add -D @rolldown/plugin-babel @babel/core
```

Move your Babel plugins and presets from `react({ babel: ... })` to `babel()` in `vite.plugins`. For example, migrate a project using `babel-plugin-styled-components` from:

```js
import react from '@astrojs/react';
import { defineConfig } from 'astro/config';

export default defineConfig({
integrations: [react({ babel: { plugins: ['babel-plugin-styled-components'] } })],
});
```

To:

```js
import react from '@astrojs/react';
import babel from '@rolldown/plugin-babel';
import { defineConfig } from 'astro/config';

export default defineConfig({
integrations: [react()],
vite: {
plugins: [
babel({
plugins: ['babel-plugin-styled-components'],
}),
],
},
});
```

This also works with `react({ compiler: true })`. Babel runs before Oxc transforms TypeScript and JSX. The Babel plugin automatically enables parsing for `.jsx`, `.ts`, and `.tsx` files. It does not load `babel.config.js` or `.babelrc` files; pass the options directly to `babel()`. The old `babel` callback is not supported; use the plugin's [`overrides` and preset hooks](https://github.com/rolldown/plugins/tree/main/packages/babel#options) for conditional transforms.

Do not enable `babel-plugin-react-compiler` on the same components as the Oxc compiler.

## Support

- Get help in the [Astro Discord][discord]. Post questions in our `#support` forum, or visit our dedicated `#dev` channel to discuss current development and more!
Expand Down
12 changes: 11 additions & 1 deletion packages/integrations/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,23 +40,28 @@
},
"dependencies": {
"@astrojs/internal-helpers": "workspace:*",
"@vitejs/plugin-react": "^5.2.0",
"@vitejs/plugin-react": "^6.1.0",
"devalue": "^5.9.2",
"ultrahtml": "^1.7.0",
"vite": "^8.3.0"
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@rolldown/plugin-babel": "^0.2.4",
"@types/babel__core": "^7.20.5",
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
"astro": "workspace:*",
"astro-scripts": "workspace:*",
"cheerio": "1.2.0",
"oxc-transform-react": "^0.145.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"peerDependencies": {
"@types/react": "^17.0.50 || ^18.0.21 || ^19.0.0",
"@types/react-dom": "^17.0.17 || ^18.0.6 || ^19.0.0",
"oxc-transform-react": "^0.145.0",
"react": "^17.0.2 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0"
},
Expand All @@ -65,5 +70,10 @@
},
"publishConfig": {
"provenance": true
},
"peerDependenciesMeta": {
"oxc-transform-react": {
"optional": true
}
}
}
41 changes: 26 additions & 15 deletions packages/integrations/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import * as devalue from 'devalue';

export type ReactIntegrationOptions = Pick<
ViteReactPluginOptions,
'include' | 'exclude' | 'babel'
'include' | 'exclude' | 'compiler'
> & {
experimentalReactChildren?: boolean;
/**
Expand Down Expand Up @@ -74,7 +74,7 @@ function getViteConfiguration(
{
include,
exclude,
babel,
compiler,
experimentalReactChildren,
experimentalDisableStreaming,
}: ReactIntegrationOptions = {},
Expand All @@ -85,16 +85,16 @@ function getViteConfiguration(
// Without excluding .astro, the filter matches .astro virtual module scripts (e.g.
// Foo.astro?astro&type=script&index=0&lang.ts) and forces lang to 'js', causing OXC
// to fail parsing TypeScript syntax like `import type`.
const astroExclude = /\.astro$/;
const defaultExclude = [/\.astro$/, /\/node_modules\//];
const mergedExclude = exclude
? Array.isArray(exclude)
? [...exclude, astroExclude]
: [exclude, astroExclude]
: astroExclude;
? [...exclude, ...defaultExclude]
: [exclude, ...defaultExclude]
: defaultExclude;

return {
plugins: [
react({ include, exclude: mergedExclude, babel }),
react({ include, exclude: mergedExclude, compiler }),
optionsPlugin({
include,
exclude,
Expand Down Expand Up @@ -164,18 +164,23 @@ function configEnvironmentPlugin(reactConfig: ReactVersionConfig): vite.Plugin {
};
}

export default function ({
include,
exclude,
babel,
experimentalReactChildren,
experimentalDisableStreaming,
}: ReactIntegrationOptions = {}): AstroIntegration {
export default function (options: ReactIntegrationOptions = {}): AstroIntegration {
if ('babel' in options) {
throw new Error(
'The @astrojs/react babel option has been removed. Configure @rolldown/plugin-babel in vite.plugins for custom Babel transforms.',
);
}
const { include, exclude, compiler, experimentalReactChildren, experimentalDisableStreaming } =
options;
const majorVersion = getReactMajorVersion();
if (!isSupportedReactVersion(majorVersion)) {
throw new Error(`Unsupported React version: ${majorVersion}.`);
}
const versionConfig = versionsConfig[majorVersion];
const compilerOptions = compiler && {
target: `${majorVersion}` as const,
...(typeof compiler === 'object' ? compiler : {}),
};

return {
name: '@astrojs/react',
Expand All @@ -184,7 +189,13 @@ export default function ({
addRenderer(getRenderer(versionConfig));
updateConfig({
vite: getViteConfiguration(
{ include, exclude, babel, experimentalReactChildren, experimentalDisableStreaming },
{
include,
exclude,
compiler: compilerOptions,
experimentalReactChildren,
experimentalDisableStreaming,
},
versionConfig,
),
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "@test/react-compiler-disabled",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/react": "workspace:*",
"astro": "workspace:*",
"oxc-transform-react": "^0.145.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-compiler-runtime": "^1.0.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { useState } from 'react';

export default function Counter({ label }: { label: string }) {
const [count, setCount] = useState(0);
return <button data-babel={"before-babel" as string} onClick={() => setCount(count + 1)}>{label}: {count}</button>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
import Counter from '../components/Counter.tsx';
---
<Counter client:load label="Count" />
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "@test/react-compiler",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/react": "workspace:*",
"astro": "workspace:*",
"oxc-transform-react": "^0.145.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-compiler-runtime": "^1.0.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { useState } from 'react';

export default function Counter({ label }: { label: string }) {
const [count, setCount] = useState(0);
return <button data-babel={"before-babel" as string} onClick={() => setCount(count + 1)}>{label}: {count}</button>;
}
Loading
Loading