Skip to content
Draft
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
3 changes: 3 additions & 0 deletions examples/emotion/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "../tsconfig.base.json"
}
12 changes: 12 additions & 0 deletions examples/prefresh/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prefresh Example</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
19 changes: 19 additions & 0 deletions examples/prefresh/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "@rolldown/example-prefresh",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"preact": "^10.29.0"
},
"devDependencies": {
"@prefresh/core": "^1.5.9",
"@prefresh/utils": "^1.2.1",
"@rolldown/plugin-prefresh": "workspace:*",
"vite": "^8.0.0"
}
}
23 changes: 23 additions & 0 deletions examples/prefresh/prefresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { expect, test } from 'vitest'
import { editFile, isServe, page } from '~utils'

test('should render app', async () => {
expect(await page.textContent('.prefresh-title')).toBe('Prefresh Works!')
})

test('context should provide value', async () => {
expect(await page.textContent('.prefresh-theme')).toBe('Current theme: dark')
})

test.runIf(isServe)('hmr works', async () => {
// Toggle theme to 'blue' via button (component state change)
await page.click('.prefresh-toggle')
await expect.poll(async () => page.textContent('.prefresh-theme')).toBe('Current theme: blue')

// Trigger HMR by editing the title
editFile('src/App.tsx', (code) => code.replace('Prefresh Works!', 'Prefresh HMR!'))
await expect.poll(async () => page.textContent('.prefresh-title')).toBe('Prefresh HMR!')

// Verify toggled context value survived HMR (state + createContext memoization)
expect(await page.textContent('.prefresh-theme')).toBe('Current theme: blue')
})
27 changes: 27 additions & 0 deletions examples/prefresh/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createContext } from 'preact'
import { useContext, useState } from 'preact/hooks'

const ThemeContext = createContext('light')

function ThemeDisplay() {
const theme = useContext(ThemeContext)
return <p className="prefresh-theme">Current theme: {theme}</p>
}

export function App() {
const [theme, setTheme] = useState('dark')
return (
<div className="prefresh-app">
<h1 className="prefresh-title">Prefresh Works!</h1>
<button
className="prefresh-toggle"
onClick={() => setTheme((t) => (t === 'dark' ? 'blue' : 'dark'))}
>
Toggle theme
</button>
<ThemeContext.Provider value={theme}>
<ThemeDisplay />
</ThemeContext.Provider>
</div>
)
}
4 changes: 4 additions & 0 deletions examples/prefresh/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { render } from 'preact'
import { App } from './App'

render(<App />, document.getElementById('root')!)
6 changes: 6 additions & 0 deletions examples/prefresh/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"jsxImportSource": "preact"
}
}
104 changes: 104 additions & 0 deletions examples/prefresh/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { defineConfig, type Plugin } from 'vite'
import { fileURLToPath } from 'node:url'
import prefresh from '@rolldown/plugin-prefresh'

const __filename = fileURLToPath(import.meta.url)

export default defineConfig({
plugins: [preactOptionsPlugin(), prefresh(), prefreshWrapperPlugin()],
})

function preactOptionsPlugin(): Plugin {
return {
name: 'preact-options',
config(_config, { command }) {
return {
oxc: {
jsx: {
importSource: 'preact',
refresh: command === 'serve',
},
jsxRefreshInclude: /\.[jt]sx$/,
},
}
},
}
}

function prefreshWrapperPlugin(): Plugin {
return {
name: 'prefresh-wrapper',
apply: 'serve',
config() {
return {
optimizeDeps: {
include: ['@prefresh/core', '@prefresh/utils'],
},
}
},
transform: {
filter: { id: { exclude: /\/node_modules\// } },
async handler(code, id) {
const hasReg = /\$RefreshReg\$\(/.test(code)
const hasSig = /\$RefreshSig\$\(/.test(code)
if (!hasSig && !hasReg) return code

const prefreshCore = (await this.resolve('@prefresh/core', __filename))!
const prefreshUtils = (await this.resolve('@prefresh/utils', __filename))!

const prelude = `
import ${JSON.stringify(prefreshCore.id)};
import { flush as flushUpdates } from ${JSON.stringify(prefreshUtils.id)};

let prevRefreshReg;
let prevRefreshSig;

if (import.meta.hot) {
prevRefreshReg = self.$RefreshReg$ || (() => {});
prevRefreshSig = self.$RefreshSig$ || (() => (type) => type);

self.$RefreshReg$ = (type, id) => {
self.__PREFRESH__.register(type, ${JSON.stringify(id)} + " " + id);
};

self.$RefreshSig$ = () => {
let status = 'begin';
let savedType;
return (type, key, forceReset, getCustomHooks) => {
if (!savedType) savedType = type;
status = self.__PREFRESH__.sign(type || savedType, key, forceReset, getCustomHooks, status);
return type;
};
};
}
`.replace(/[\n]+/gm, '')

if (hasSig && !hasReg) {
return {
code: `${prelude}${code}`,
map: null,
}
}

return {
code: `${prelude}${code}

if (import.meta.hot) {
self.$RefreshReg$ = prevRefreshReg;
self.$RefreshSig$ = prevRefreshSig;
import.meta.hot.accept((m) => {
try {
flushUpdates();
} catch (e) {
console.log('[PREFRESH] Failed to flush updates:', e);
self.location.reload();
}
});
}
`,
map: null,
}
},
},
}
}
File renamed without changes.
4 changes: 4 additions & 0 deletions examples/tsconfig.root.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.base.json",
"include": ["./*"]
}
4 changes: 4 additions & 0 deletions examples/vitestGlobalSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@ export async function setup({ provide, config }: TestProject): Promise<void> {

const isBuild = !!config.provide.isBuild
tempBaseDir = path.join(import.meta.dirname, `../examples-temp-${isBuild ? 'build' : 'dev'}`)
const tsconfigBaseDest = path.join(tempBaseDir, './tsconfig.base.json')

if (!fs.existsSync(tempBaseDir)) {
fs.mkdirSync(tempBaseDir, { recursive: true })
}
if (!fs.existsSync(tsconfigBaseDest)) {
fs.copyFileSync(path.join(import.meta.dirname, './tsconfig.base.json'), tsconfigBaseDest)
}
provide('tempBaseDir', tempBaseDir)
}

Expand Down
1 change: 1 addition & 0 deletions internal-packages/swc-output-gen/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"@rollup/plugin-swc": "^0.4.0",
"@swc/core": "^1.15.18",
"@swc/plugin-emotion": "^14.7.0",
"@swc/plugin-prefresh": "^12.7.0",
"rolldown": "^1.0.0-rc.9",
"tinyglobby": "^0.2.15"
}
Expand Down
4 changes: 4 additions & 0 deletions internal-packages/swc-output-gen/src/plugin-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export const pluginRegistry: Record<string, PluginConfig> = {
mapOptions: (config) => [['@swc/plugin-emotion', config]],
shouldSkip: () => false,
},
prefresh: {
packages: ['@swc/plugin-prefresh'],
mapOptions: (config) => [['@swc/plugin-prefresh', config]],
},
}

/** Get list of all supported plugin names */
Expand Down
80 changes: 80 additions & 0 deletions packages/prefresh/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# @rolldown/plugin-prefresh [![npm](https://img.shields.io/npm/v/@rolldown/plugin-prefresh.svg)](https://npmx.dev/package/@rolldown/plugin-prefresh)

Rolldown plugin for [Prefresh](https://github.com/preactjs/prefresh) (HMR support for [Preact](https://github.com/preactjs/preact)).

This plugin memoizes `createContext()` calls to preserve context identity across hot module replacement cycles. It utilizes Rolldown's [native magic string API](https://rolldown.rs/in-depth/native-magic-string) instead of Babel and is more performant than using `@prefresh/babel-plugin` with [`@rolldown/plugin-babel`](https://npmx.dev/package/@rolldown/plugin-babel).

This plugin is meant to be used together with the React refresh transform in Oxc.

## Install

```bash
pnpm add -D @rolldown/plugin-prefresh
```

## Usage

```js
import prefresh from '@rolldown/plugin-prefresh'

export default {
plugins: [
prefresh({
// options
}),
],
}
```

## Options

### `library`

- **Type:** `string[]`
- **Default:** `['preact', 'react', 'preact/compat']`

Libraries to detect `createContext` imports from. Override this to add or restrict which packages are scanned.

```js
prefresh({
library: ['preact', 'preact/compat'],
})
```

### `enabled`

- **Type:** `boolean`
- **Default:** `true` in development, `false` otherwise

Enable or disable the transform. When used with Vite, the plugin automatically detects the environment. When used with Rolldown directly, it checks `process.env.NODE_ENV`.

## Benchmark

Results of the benchmark that can be run by `pnpm bench` in `./benchmark` directory:

```
name hz min max mean p75 p99 p995 p999 rme samples
· @rolldown/plugin-prefresh 7.7340 123.59 140.14 129.30 129.53 140.14 140.14 140.14 ±2.57% 10
· @rolldown/plugin-babel 3.6874 254.66 374.95 271.19 263.76 374.95 374.95 374.95 ±9.70% 10
· @rollup/plugin-swc 6.7767 143.32 166.00 147.56 146.57 166.00 166.00 166.00 ±3.17% 10

@rolldown/plugin-prefresh - bench/prefresh.bench.ts > Prefresh Benchmark
1.14x faster than @rollup/plugin-swc
2.10x faster than @rolldown/plugin-babel
```

The benchmark was ran on the following environment:

```
OS: macOS Tahoe 26.3
CPU: Apple M4
Memory: LPDDR5X-7500 32GB
```

## License

MIT

## Credits

The implementation is based on [swc-project/plugins/packages/prefresh](https://github.com/swc-project/plugins/tree/main/packages/prefresh) ([Apache License 2.0](https://github.com/swc-project/plugins/blob/main/LICENSE)). Test cases are also adapted from it.
8 changes: 8 additions & 0 deletions packages/prefresh/benchmark/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Build outputs
dist/

# Generated components (regenerated with pnpm generate)
shared-app/src/components/

# SWC plugin cache
.swc/
52 changes: 52 additions & 0 deletions packages/prefresh/benchmark/bench/prefresh.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { bench, describe } from 'vitest'
import { execSync } from 'node:child_process'
import { existsSync, rmSync } from 'node:fs'
import { resolve } from 'node:path'

const baseDir = resolve(import.meta.dirname, '..')
const distBase = resolve(baseDir, 'dist')
const componentsDir = resolve(baseDir, 'shared-app/src/components')

if (!existsSync(componentsDir)) {
execSync('pnpm generate', { cwd: baseDir, stdio: 'inherit' })
}

function cleanDist(name: string) {
const dir = resolve(distBase, name)
if (existsSync(dir)) {
rmSync(dir, { recursive: true })
}
}

function runBuild(name: string) {
execSync(`rolldown -c configs/${name}.ts`, {
cwd: baseDir,
stdio: 'pipe',
})
}

describe('Prefresh Benchmark', () => {
bench(
'@rolldown/plugin-prefresh',
() => {
runBuild('custom')
},
{ teardown: () => cleanDist('custom') },
)

bench(
'@rolldown/plugin-babel',
() => {
runBuild('babel')
},
{ teardown: () => cleanDist('babel') },
)

bench(
'@rollup/plugin-swc',
() => {
runBuild('swc')
},
{ teardown: () => cleanDist('swc') },
)
})
Loading