diff --git a/examples/emotion/tsconfig.json b/examples/emotion/tsconfig.json
new file mode 100644
index 0000000..4eb37fe
--- /dev/null
+++ b/examples/emotion/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "../tsconfig.base.json"
+}
diff --git a/examples/prefresh/index.html b/examples/prefresh/index.html
new file mode 100644
index 0000000..538615b
--- /dev/null
+++ b/examples/prefresh/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Prefresh Example
+
+
+
+
+
+
diff --git a/examples/prefresh/package.json b/examples/prefresh/package.json
new file mode 100644
index 0000000..6f1f454
--- /dev/null
+++ b/examples/prefresh/package.json
@@ -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"
+ }
+}
diff --git a/examples/prefresh/prefresh.test.ts b/examples/prefresh/prefresh.test.ts
new file mode 100644
index 0000000..c838d83
--- /dev/null
+++ b/examples/prefresh/prefresh.test.ts
@@ -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')
+})
diff --git a/examples/prefresh/src/App.tsx b/examples/prefresh/src/App.tsx
new file mode 100644
index 0000000..e9107f3
--- /dev/null
+++ b/examples/prefresh/src/App.tsx
@@ -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 Current theme: {theme}
+}
+
+export function App() {
+ const [theme, setTheme] = useState('dark')
+ return (
+
+
Prefresh Works!
+
+
+
+
+
+ )
+}
diff --git a/examples/prefresh/src/main.tsx b/examples/prefresh/src/main.tsx
new file mode 100644
index 0000000..7005aae
--- /dev/null
+++ b/examples/prefresh/src/main.tsx
@@ -0,0 +1,4 @@
+import { render } from 'preact'
+import { App } from './App'
+
+render(, document.getElementById('root')!)
diff --git a/examples/prefresh/tsconfig.json b/examples/prefresh/tsconfig.json
new file mode 100644
index 0000000..7d33cd6
--- /dev/null
+++ b/examples/prefresh/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "jsxImportSource": "preact"
+ }
+}
diff --git a/examples/prefresh/vite.config.ts b/examples/prefresh/vite.config.ts
new file mode 100644
index 0000000..d600ce5
--- /dev/null
+++ b/examples/prefresh/vite.config.ts
@@ -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,
+ }
+ },
+ },
+ }
+}
diff --git a/examples/tsconfig.json b/examples/tsconfig.base.json
similarity index 100%
rename from examples/tsconfig.json
rename to examples/tsconfig.base.json
diff --git a/examples/tsconfig.root.json b/examples/tsconfig.root.json
new file mode 100644
index 0000000..d755746
--- /dev/null
+++ b/examples/tsconfig.root.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.base.json",
+ "include": ["./*"]
+}
diff --git a/examples/vitestGlobalSetup.ts b/examples/vitestGlobalSetup.ts
index ca419c5..506ee9d 100644
--- a/examples/vitestGlobalSetup.ts
+++ b/examples/vitestGlobalSetup.ts
@@ -16,10 +16,14 @@ export async function setup({ provide, config }: TestProject): Promise {
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)
}
diff --git a/internal-packages/swc-output-gen/package.json b/internal-packages/swc-output-gen/package.json
index 527947e..9036c59 100644
--- a/internal-packages/swc-output-gen/package.json
+++ b/internal-packages/swc-output-gen/package.json
@@ -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"
}
diff --git a/internal-packages/swc-output-gen/src/plugin-registry.ts b/internal-packages/swc-output-gen/src/plugin-registry.ts
index 247b8cf..37645be 100644
--- a/internal-packages/swc-output-gen/src/plugin-registry.ts
+++ b/internal-packages/swc-output-gen/src/plugin-registry.ts
@@ -26,6 +26,10 @@ export const pluginRegistry: Record = {
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 */
diff --git a/packages/prefresh/README.md b/packages/prefresh/README.md
new file mode 100644
index 0000000..285de5f
--- /dev/null
+++ b/packages/prefresh/README.md
@@ -0,0 +1,80 @@
+# @rolldown/plugin-prefresh [](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.
diff --git a/packages/prefresh/benchmark/.gitignore b/packages/prefresh/benchmark/.gitignore
new file mode 100644
index 0000000..8bab002
--- /dev/null
+++ b/packages/prefresh/benchmark/.gitignore
@@ -0,0 +1,8 @@
+# Build outputs
+dist/
+
+# Generated components (regenerated with pnpm generate)
+shared-app/src/components/
+
+# SWC plugin cache
+.swc/
diff --git a/packages/prefresh/benchmark/bench/prefresh.bench.ts b/packages/prefresh/benchmark/bench/prefresh.bench.ts
new file mode 100644
index 0000000..e11c5fe
--- /dev/null
+++ b/packages/prefresh/benchmark/bench/prefresh.bench.ts
@@ -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') },
+ )
+})
diff --git a/packages/prefresh/benchmark/configs/babel.ts b/packages/prefresh/benchmark/configs/babel.ts
new file mode 100644
index 0000000..557da19
--- /dev/null
+++ b/packages/prefresh/benchmark/configs/babel.ts
@@ -0,0 +1,27 @@
+import { defineConfig } from 'rolldown'
+import { resolve } from 'node:path'
+import babel, { defineRolldownBabelPreset } from '@rolldown/plugin-babel'
+
+const prefreshPreset = defineRolldownBabelPreset({
+ preset: () => ({
+ plugins: [['@prefresh/babel-plugin', { skipEnvCheck: true }]],
+ }),
+ rolldown: {
+ filter: {
+ id: { include: /\.[jt]sx?$/, exclude: /node_modules/ },
+ code: /createContext/,
+ },
+ },
+})
+
+export default defineConfig({
+ input: resolve(import.meta.dirname, '../shared-app/src/index.tsx'),
+ output: {
+ dir: resolve(import.meta.dirname, '../dist/babel'),
+ },
+ plugins: [
+ babel({
+ presets: [prefreshPreset],
+ }),
+ ],
+})
diff --git a/packages/prefresh/benchmark/configs/custom.ts b/packages/prefresh/benchmark/configs/custom.ts
new file mode 100644
index 0000000..92ca803
--- /dev/null
+++ b/packages/prefresh/benchmark/configs/custom.ts
@@ -0,0 +1,17 @@
+import { defineConfig } from 'rolldown'
+import { resolve } from 'node:path'
+import prefresh from '@rolldown/plugin-prefresh'
+
+export default defineConfig({
+ input: resolve(import.meta.dirname, '../shared-app/src/index.tsx'),
+ output: {
+ dir: resolve(import.meta.dirname, '../dist/custom'),
+ },
+ transform: {
+ jsx: {
+ development: true,
+ refresh: true,
+ },
+ },
+ plugins: [prefresh({ enabled: true })],
+})
diff --git a/packages/prefresh/benchmark/configs/swc.ts b/packages/prefresh/benchmark/configs/swc.ts
new file mode 100644
index 0000000..39b5c83
--- /dev/null
+++ b/packages/prefresh/benchmark/configs/swc.ts
@@ -0,0 +1,40 @@
+import { defineConfig } from 'rolldown'
+import { resolve } from 'node:path'
+import { withFilter } from 'rolldown/filter'
+import swc from '@rollup/plugin-swc'
+
+export default defineConfig({
+ input: resolve(import.meta.dirname, '../shared-app/src/index.tsx'),
+ output: {
+ dir: resolve(import.meta.dirname, '../dist/swc'),
+ },
+ plugins: [
+ withFilter(
+ swc({
+ swc: {
+ jsc: {
+ parser: {
+ syntax: 'typescript',
+ tsx: true,
+ },
+ transform: {
+ react: {
+ runtime: 'automatic',
+ development: true,
+ refresh: true,
+ },
+ },
+ experimental: {
+ plugins: [['@swc/plugin-prefresh', {}]],
+ },
+ },
+ },
+ }),
+ {
+ transform: {
+ id: { include: /\.[jt]sx?$/ },
+ },
+ },
+ ),
+ ],
+})
diff --git a/packages/prefresh/benchmark/package.json b/packages/prefresh/benchmark/package.json
new file mode 100644
index 0000000..a642037
--- /dev/null
+++ b/packages/prefresh/benchmark/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "@rolldown/benchmark-prefresh",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "generate": "oxnode scripts/generate-app.ts",
+ "bench": "vitest bench --run",
+ "build:custom": "rolldown -c configs/custom.ts",
+ "build:babel": "rolldown -c configs/babel.ts",
+ "build:swc": "rolldown -c configs/swc.ts"
+ },
+ "dependencies": {
+ "preact": "^10.29.0"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.29.0",
+ "@oxc-node/cli": "^0.0.35",
+ "@prefresh/babel-plugin": "^0.5.3",
+ "@rolldown/benchmark-utils": "workspace:*",
+ "@rolldown/plugin-babel": "file:../../babel",
+ "@rolldown/plugin-prefresh": "workspace:*",
+ "@rollup/plugin-swc": "^0.4.0",
+ "@swc/core": "^1.15.18",
+ "@swc/plugin-prefresh": "^12.7.0",
+ "@types/node": "^24.10.13",
+ "rolldown": "^1.0.0-rc.9"
+ }
+}
diff --git a/packages/prefresh/benchmark/scripts/generate-app.ts b/packages/prefresh/benchmark/scripts/generate-app.ts
new file mode 100644
index 0000000..f6861f7
--- /dev/null
+++ b/packages/prefresh/benchmark/scripts/generate-app.ts
@@ -0,0 +1,265 @@
+/**
+ * Component generator for Prefresh benchmark.
+ * Generates ~100 React components using createContext patterns.
+ * Uses seeded random (seed=42) for deterministic generation.
+ */
+
+import { writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs'
+import { join } from 'node:path'
+import { SeededRandom } from '@rolldown/benchmark-utils/seeded-random'
+
+const rng = new SeededRandom(42)
+
+type ComponentType = 'ThemeContext' | 'AuthContext' | 'ConfigContext' | 'DataContext' | 'UIContext'
+const COMPONENT_TYPES: ComponentType[] = [
+ 'ThemeContext',
+ 'AuthContext',
+ 'ConfigContext',
+ 'DataContext',
+ 'UIContext',
+]
+
+const THEMES = ['light', 'dark', 'auto', 'system']
+const ROLES = ['admin', 'user', 'editor', 'viewer']
+
+function generateThemeContext(index: number): string {
+ const defaultTheme = rng.pick(THEMES)
+ const hasToggle = rng.next() > 0.3
+
+ return `import React, { createContext, useContext, useState } from 'react'
+
+interface ThemeState${index} {
+ theme: string
+ primaryColor: string
+${hasToggle ? ' toggleTheme: () => void' : ''}
+}
+
+export const ThemeContext${index} = createContext({
+ theme: '${defaultTheme}',
+ primaryColor: '#4ecdc4',
+${hasToggle ? ' toggleTheme: () => {},' : ''}
+})
+
+export function ThemeProvider${index}({ children }: { children: React.ReactNode }) {
+ const [theme, setTheme] = useState('${defaultTheme}')
+
+ return (
+ setTheme(t => t === 'light' ? 'dark' : 'light'),` : ''}
+ }}>
+ {children}
+
+ )
+}
+
+export function ThemeContext${index}Consumer() {
+ const ctx = useContext(ThemeContext${index})
+ return Theme: {ctx.theme}
+}
+`
+}
+
+function generateAuthContext(index: number): string {
+ const defaultRole = rng.pick(ROLES)
+
+ return `import React, { createContext, useContext, useState } from 'react'
+
+interface AuthState${index} {
+ isAuthenticated: boolean
+ role: string
+ login: (role: string) => void
+ logout: () => void
+}
+
+export const AuthContext${index} = createContext({
+ isAuthenticated: false,
+ role: '${defaultRole}',
+ login: () => {},
+ logout: () => {},
+})
+
+export function AuthProvider${index}({ children }: { children: React.ReactNode }) {
+ const [auth, setAuth] = useState({ isAuthenticated: false, role: '${defaultRole}' })
+
+ return (
+ setAuth({ isAuthenticated: true, role }),
+ logout: () => setAuth({ isAuthenticated: false, role: '${defaultRole}' }),
+ }}>
+ {children}
+
+ )
+}
+
+export function AuthContext${index}Consumer() {
+ const ctx = useContext(AuthContext${index})
+ return Auth: {ctx.isAuthenticated ? ctx.role : 'anonymous'}
+}
+`
+}
+
+function generateConfigContext(index: number): string {
+ const hasApi = rng.next() > 0.5
+ const hasDebug = rng.next() > 0.5
+
+ return `import React, { createContext, useContext } from 'react'
+
+interface Config${index} {
+ appName: string
+ version: string
+${hasApi ? ' apiUrl: string' : ''}
+${hasDebug ? ' debug: boolean' : ''}
+}
+
+export const ConfigContext${index} = createContext({
+ appName: 'App${index}',
+ version: '1.0.0',
+${hasApi ? " apiUrl: 'https://api.example.com'," : ''}
+${hasDebug ? ' debug: false,' : ''}
+})
+
+export function ConfigProvider${index}({ children, config }: { children: React.ReactNode; config?: Partial }) {
+ const defaultConfig: Config${index} = {
+ appName: 'App${index}',
+ version: '1.0.0',
+${hasApi ? " apiUrl: 'https://api.example.com'," : ''}
+${hasDebug ? ' debug: false,' : ''}
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function ConfigContext${index}Consumer() {
+ const ctx = useContext(ConfigContext${index})
+ return Config: {ctx.appName} v{ctx.version}
+}
+`
+}
+
+function generateDataContext(index: number): string {
+ return `import React, { createContext, useContext, useState } from 'react'
+
+interface DataState${index} {
+ data: unknown[]
+ loading: boolean
+ error: string | null
+ refresh: () => void
+}
+
+export const DataContext${index} = createContext({
+ data: [],
+ loading: false,
+ error: null,
+ refresh: () => {},
+})
+
+export function DataProvider${index}({ children }: { children: React.ReactNode }) {
+ const [state, setState] = useState({ data: [] as unknown[], loading: false, error: null as string | null })
+
+ const refresh = () => {
+ setState(s => ({ ...s, loading: true }))
+ setState(s => ({ ...s, data: [{ id: ${index} }], loading: false }))
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function DataContext${index}Consumer() {
+ const ctx = useContext(DataContext${index})
+ return Data: {ctx.loading ? 'Loading...' : ctx.data.length + ' items'}
+}
+`
+}
+
+function generateUIContext(index: number): string {
+ const hasModal = rng.next() > 0.5
+ const hasSidebar = rng.next() > 0.5
+
+ return `import React, { createContext, useContext, useState } from 'react'
+
+interface UIState${index} {
+${hasModal ? ' modalOpen: boolean\n toggleModal: () => void' : ''}
+${hasSidebar ? ' sidebarOpen: boolean\n toggleSidebar: () => void' : ''}
+ notifications: number
+}
+
+export const UIContext${index} = createContext({
+${hasModal ? ' modalOpen: false,\n toggleModal: () => {},' : ''}
+${hasSidebar ? ' sidebarOpen: true,\n toggleSidebar: () => {},' : ''}
+ notifications: 0,
+})
+
+export function UIProvider${index}({ children }: { children: React.ReactNode }) {
+${hasModal ? ' const [modalOpen, setModalOpen] = useState(false)' : ''}
+${hasSidebar ? ' const [sidebarOpen, setSidebarOpen] = useState(true)' : ''}
+ const [notifications] = useState(0)
+
+ return (
+ setModalOpen(o => !o),' : ''}
+${hasSidebar ? ' sidebarOpen,\n toggleSidebar: () => setSidebarOpen(o => !o),' : ''}
+ notifications,
+ }}>
+ {children}
+
+ )
+}
+
+export function UIContext${index}Consumer() {
+ const ctx = useContext(UIContext${index})
+ return UI: {ctx.notifications} notifications
+}
+`
+}
+
+const GENERATORS: Record string> = {
+ ThemeContext: generateThemeContext,
+ AuthContext: generateAuthContext,
+ ConfigContext: generateConfigContext,
+ DataContext: generateDataContext,
+ UIContext: generateUIContext,
+}
+
+function main() {
+ const componentsDir = join(import.meta.dirname, '../shared-app/src/components')
+ if (existsSync(componentsDir)) rmSync(componentsDir, { recursive: true })
+ mkdirSync(componentsDir, { recursive: true })
+
+ const components: Array<{ type: ComponentType; index: number }> = []
+ const TOTAL = 100
+ const perType = Math.floor(TOTAL / COMPONENT_TYPES.length)
+ const remainder = TOTAL % COMPONENT_TYPES.length
+
+ for (let i = 0; i < COMPONENT_TYPES.length; i++) {
+ const type = COMPONENT_TYPES[i]
+ const count = perType + (i < remainder ? 1 : 0)
+ for (let j = 0; j < count; j++) {
+ const index = components.length + 1
+ components.push({ type, index })
+ writeFileSync(join(componentsDir, `${type}${index}.tsx`), GENERATORS[type](index))
+ }
+ }
+
+ const exports = components
+ .map(({ type, index }) => `export * from './${type}${index}.js'`)
+ .join('\n')
+ writeFileSync(join(componentsDir, 'index.ts'), exports + '\n')
+
+ console.log(`Generated ${components.length} components in ${componentsDir}`)
+ for (const type of COMPONENT_TYPES) {
+ console.log(` ${type}: ${components.filter((c) => c.type === type).length}`)
+ }
+}
+
+main()
diff --git a/packages/prefresh/benchmark/shared-app/src/App.tsx b/packages/prefresh/benchmark/shared-app/src/App.tsx
new file mode 100644
index 0000000..0b7ab1c
--- /dev/null
+++ b/packages/prefresh/benchmark/shared-app/src/App.tsx
@@ -0,0 +1,24 @@
+import React from 'react'
+import * as Components from './components/index.js'
+
+// Get all Consumer components for rendering
+// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
+const consumerEntries = Object.entries(Components).filter(([name]) =>
+ name.endsWith('Consumer'),
+) as [string, React.ComponentType][]
+
+export function App() {
+ return (
+
+
Prefresh Benchmark App
+
This app contains {consumerEntries.length} context consumers for benchmarking.
+
+ {consumerEntries.map(([name, Consumer]) => (
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/packages/prefresh/benchmark/shared-app/src/index.tsx b/packages/prefresh/benchmark/shared-app/src/index.tsx
new file mode 100644
index 0000000..07c809b
--- /dev/null
+++ b/packages/prefresh/benchmark/shared-app/src/index.tsx
@@ -0,0 +1,13 @@
+import React from 'react'
+import { createRoot } from 'react-dom/client'
+import { App } from './App.js'
+
+const container = document.getElementById('root')
+if (container) {
+ const root = createRoot(container)
+ root.render(
+
+
+ ,
+ )
+}
diff --git a/packages/prefresh/benchmark/tsconfig.json b/packages/prefresh/benchmark/tsconfig.json
new file mode 100644
index 0000000..30247a2
--- /dev/null
+++ b/packages/prefresh/benchmark/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "preserve",
+ "moduleResolution": "bundler",
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true,
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "preact"
+ },
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/prefresh/benchmark/vitest.config.ts b/packages/prefresh/benchmark/vitest.config.ts
new file mode 100644
index 0000000..ed43ffe
--- /dev/null
+++ b/packages/prefresh/benchmark/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ name: 'benchmark-prefresh',
+ },
+})
diff --git a/packages/prefresh/package.json b/packages/prefresh/package.json
new file mode 100644
index 0000000..76c1574
--- /dev/null
+++ b/packages/prefresh/package.json
@@ -0,0 +1,62 @@
+{
+ "name": "@rolldown/plugin-prefresh",
+ "version": "0.1.0",
+ "description": "Rolldown plugin for Prefresh (Preact HMR context memoization)",
+ "keywords": [
+ "hmr",
+ "plugin",
+ "preact",
+ "prefresh",
+ "rolldown",
+ "rolldown-plugin"
+ ],
+ "homepage": "https://github.com/rolldown/plugins/tree/main/packages/prefresh#readme",
+ "bugs": {
+ "url": "https://github.com/rolldown/plugins/issues"
+ },
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/rolldown/plugins.git",
+ "directory": "packages/prefresh"
+ },
+ "files": [
+ "dist"
+ ],
+ "type": "module",
+ "exports": "./dist/index.mjs",
+ "scripts": {
+ "dev": "tsdown --watch",
+ "build": "tsdown",
+ "test": "vitest --project prefresh",
+ "prepublishOnly": "pnpm run build"
+ },
+ "dependencies": {
+ "@rolldown/oxc-unshadowed-visitor": "workspace:*",
+ "rolldown-string": "^0.3.0"
+ },
+ "devDependencies": {
+ "rolldown": "^1.0.0-rc.9",
+ "tinyglobby": "^0.2.15",
+ "vite": "^8.0.0"
+ },
+ "peerDependencies": {
+ "rolldown": "^1.0.0-rc.9",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ },
+ "engines": {
+ "node": ">=22.12.0 || ^24.0.0"
+ },
+ "compatiblePackages": {
+ "schemaVersion": 1,
+ "rollup": {
+ "type": "incompatible",
+ "reason": "Uses Rolldown-specific APIs"
+ }
+ }
+}
diff --git a/packages/prefresh/src/index.ts b/packages/prefresh/src/index.ts
new file mode 100644
index 0000000..72cd013
--- /dev/null
+++ b/packages/prefresh/src/index.ts
@@ -0,0 +1,287 @@
+import crypto from 'node:crypto'
+import { withMagicString } from 'rolldown-string'
+import type { Plugin } from 'rolldown'
+import type { ESTree } from 'rolldown/utils'
+import { ScopedVisitor } from '@rolldown/oxc-unshadowed-visitor'
+import type { PrefreshPluginOptions } from './types.ts'
+
+export type { PrefreshPluginOptions } from './types.ts'
+
+const DEFAULT_LIBRARY = ['preact', 'react', 'preact/compat']
+
+interface RecordData {
+ callNode: ESTree.CallExpression
+ parentKey: string
+ paramNames: string[]
+}
+
+function resolveLibrary(options: PrefreshPluginOptions): Set {
+ return new Set(options.library ?? DEFAULT_LIBRARY)
+}
+
+function createFileHash(id: string): string {
+ return crypto.hash('sha256', id, 'hex').slice(0, 16)
+}
+
+function getSimpleParamNames(params: ESTree.ParamPattern[]): string[] {
+ const names: string[] = []
+ for (const parameter of params) {
+ if (parameter.type === 'Identifier') {
+ names.push(parameter.name)
+ }
+ }
+ return names
+}
+
+function getObjectPatternKey(property: ESTree.Node & { type: 'Property' }): string | null {
+ if (property.computed) return null
+ if (property.key.type === 'Identifier') {
+ return property.key.name
+ }
+ if (property.key.type === 'Literal') {
+ return String(property.key.value)
+ }
+ return null
+}
+
+function buildContextKey(
+ fileHash: string,
+ parentKey: string,
+ count: number,
+ paramNames: readonly string[],
+): string {
+ const base = `${fileHash}${parentKey}${count}`
+ if (paramNames.length === 0) {
+ return `\`${base}\``
+ }
+
+ const suffix = paramNames.map((name) => `\${${name}}`).join('')
+ return `\`${base}_${suffix}\``
+}
+
+/**
+ * Prefresh plugin for Rolldown
+ *
+ * Memoizes createContext() calls from Preact/React to preserve context identity
+ * across HMR cycles. Wraps createContext() calls with a caching pattern using
+ * the function itself as a cache object.
+ */
+export default function prefreshPlugin(options: PrefreshPluginOptions = {}): Plugin {
+ const libraries = resolveLibrary(options)
+ let isEnabled = options.enabled!
+
+ const plugin: Plugin = {
+ name: 'rolldown-plugin-prefresh',
+ // @ts-expect-error Vite-specific property
+ enforce: 'pre',
+
+ // @ts-expect-error Vite-specific hook
+ configResolved(config) {
+ isEnabled ??= !config.isProduction
+ if (!isEnabled) {
+ delete plugin.transform
+ }
+ },
+
+ outputOptions() {
+ if ('viteVersion' in this.meta) return
+ isEnabled ??= process.env.NODE_ENV === 'development'
+ if (!isEnabled) {
+ delete plugin.transform
+ }
+ },
+
+ transform: {
+ filter: {
+ id: /\.[jt]sx?$/,
+ code: {
+ include: 'createContext',
+ },
+ },
+
+ handler: withMagicString(function (this, s, id, meta) {
+ const lang = id.endsWith('.tsx')
+ ? 'tsx'
+ : id.endsWith('.ts')
+ ? 'ts'
+ : id.endsWith('.jsx')
+ ? 'jsx'
+ : 'js'
+ const program = meta?.ast ?? this.parse(s.original, { lang })
+
+ // ── Phase 1: Scan imports ──
+ const namedImports = new Set()
+ const namespaceImports = new Set()
+
+ for (const node of program.body) {
+ if (node.type !== 'ImportDeclaration') continue
+ if (!libraries.has(node.source.value)) continue
+
+ for (const specifier of node.specifiers) {
+ if (specifier.type === 'ImportSpecifier') {
+ const importedName =
+ specifier.imported.type === 'Identifier'
+ ? specifier.imported.name
+ : specifier.imported.value
+ if (importedName === 'createContext') {
+ namedImports.add(specifier.local.name)
+ }
+ } else if (
+ specifier.type === 'ImportDefaultSpecifier' ||
+ specifier.type === 'ImportNamespaceSpecifier'
+ ) {
+ namespaceImports.add(specifier.local.name)
+ }
+ }
+ }
+
+ const trackedNames = [...namedImports, ...namespaceImports]
+ if (trackedNames.length === 0) return
+
+ // ── Phase 2: Walk with ScopedVisitor ──
+ const paramNamesStack: string[][] = [[]]
+ const parentKeyStack: string[] = ['']
+ let objectPatternDepth = 0
+
+ const sv = new ScopedVisitor({
+ trackedNames,
+ visitor: {
+ FunctionDeclaration(node) {
+ paramNamesStack.push(getSimpleParamNames(node.params))
+ },
+ 'FunctionDeclaration:exit'() {
+ paramNamesStack.pop()
+ },
+
+ FunctionExpression(node) {
+ paramNamesStack.push(getSimpleParamNames(node.params))
+ },
+ 'FunctionExpression:exit'() {
+ paramNamesStack.pop()
+ },
+
+ ArrowFunctionExpression(node) {
+ paramNamesStack.push(getSimpleParamNames(node.params))
+ },
+ 'ArrowFunctionExpression:exit'() {
+ paramNamesStack.pop()
+ },
+
+ VariableDeclarator(node) {
+ if (node.id.type === 'Identifier') {
+ parentKeyStack.push(`$${node.id.name}`)
+ } else {
+ parentKeyStack.push(parentKeyStack[parentKeyStack.length - 1])
+ }
+ },
+ 'VariableDeclarator:exit'() {
+ parentKeyStack.pop()
+ },
+
+ AssignmentExpression(node) {
+ if (node.left.type === 'Identifier') {
+ parentKeyStack.push(`_${node.left.name}`)
+ } else {
+ parentKeyStack.push(parentKeyStack[parentKeyStack.length - 1])
+ }
+ },
+ 'AssignmentExpression:exit'() {
+ parentKeyStack.pop()
+ },
+
+ ObjectPattern() {
+ objectPatternDepth++
+ },
+ 'ObjectPattern:exit'() {
+ objectPatternDepth--
+ },
+
+ Property(node) {
+ if (objectPatternDepth > 0) {
+ const key = getObjectPatternKey(node)
+ if (key) {
+ parentKeyStack.push(`__${key}`)
+ return
+ }
+ }
+ parentKeyStack.push(parentKeyStack[parentKeyStack.length - 1])
+ },
+ 'Property:exit'() {
+ parentKeyStack.pop()
+ },
+
+ CallExpression(node, ctx) {
+ const callee = node.callee
+ const parentKey = parentKeyStack[parentKeyStack.length - 1]
+ const paramNames = paramNamesStack[paramNamesStack.length - 1]
+
+ if (callee.type === 'Identifier' && namedImports.has(callee.name)) {
+ ctx.record({
+ name: callee.name,
+ node,
+ data: { callNode: node, parentKey, paramNames },
+ })
+ return
+ }
+
+ if (
+ callee.type === 'MemberExpression' &&
+ callee.object.type === 'Identifier' &&
+ namespaceImports.has(callee.object.name)
+ ) {
+ const isCreateContext = callee.computed
+ ? callee.property.type === 'Literal' &&
+ typeof callee.property.value === 'string' &&
+ callee.property.value === 'createContext'
+ : callee.property.type === 'Identifier' &&
+ callee.property.name === 'createContext'
+
+ if (isCreateContext) {
+ ctx.record({
+ name: callee.object.name,
+ node,
+ data: { callNode: node, parentKey, paramNames },
+ })
+ }
+ }
+ },
+ },
+ })
+
+ const records = sv.walk(program)
+ if (records.length === 0) return
+
+ // ── Phase 3: Apply transformations ──
+ const counters = new Map()
+ const fileHash = createFileHash(id)
+
+ for (const record of records) {
+ const { callNode, parentKey, paramNames } = record.data
+ const counter = (counters.get(parentKey) ?? 0) + 1
+ counters.set(parentKey, counter)
+
+ const callee = s.slice(callNode.callee.start, callNode.callee.end)
+ const key = buildContextKey(fileHash, parentKey, counter, paramNames)
+
+ const firstArg = callNode.arguments[0]
+ if (firstArg && firstArg.type !== 'SpreadElement') {
+ const value = s.slice(firstArg.start, firstArg.end)
+ s.update(
+ callNode.start,
+ callNode.end,
+ `Object.assign(${callee}[${key}] || (${callee}[${key}] = ${callee}(${value})), { __: ${value} })`,
+ )
+ continue
+ }
+
+ s.update(
+ callNode.start,
+ callNode.end,
+ `${callee}[${key}] || (${callee}[${key}] = ${callee}())`,
+ )
+ }
+ }),
+ },
+ }
+ return plugin
+}
diff --git a/packages/prefresh/src/types.ts b/packages/prefresh/src/types.ts
new file mode 100644
index 0000000..90d79a8
--- /dev/null
+++ b/packages/prefresh/src/types.ts
@@ -0,0 +1,13 @@
+export interface PrefreshPluginOptions {
+ /**
+ * Libraries to detect `createContext` imports from.
+ * @default ['preact', 'react', 'preact/compat']
+ */
+ library?: string[]
+
+ /**
+ * Enable transform
+ * @default true for development, otherwise false
+ */
+ enabled?: boolean
+}
diff --git a/packages/prefresh/tests/fixtures/basic/input.js b/packages/prefresh/tests/fixtures/basic/input.js
new file mode 100644
index 0000000..2871831
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/basic/input.js
@@ -0,0 +1,5 @@
+import { createContext } from 'preact';
+
+export function aaa() {
+ const context = createContext();
+}
diff --git a/packages/prefresh/tests/fixtures/basic/output.js b/packages/prefresh/tests/fixtures/basic/output.js
new file mode 100644
index 0000000..2cd52b4
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/basic/output.js
@@ -0,0 +1,7 @@
+import { createContext } from "preact";
+//#region virtual:entry.js
+function aaa() {
+ createContext[`1e87a657330148b0$context1`] || (createContext[`1e87a657330148b0$context1`] = createContext());
+}
+//#endregion
+export { aaa };
diff --git a/packages/prefresh/tests/fixtures/import-custom-preact/config.json b/packages/prefresh/tests/fixtures/import-custom-preact/config.json
new file mode 100644
index 0000000..d0bf81e
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/import-custom-preact/config.json
@@ -0,0 +1,3 @@
+{
+ "library": ["@custom/preact", "preact", "react"]
+}
diff --git a/packages/prefresh/tests/fixtures/import-custom-preact/input.js b/packages/prefresh/tests/fixtures/import-custom-preact/input.js
new file mode 100644
index 0000000..49ef55d
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/import-custom-preact/input.js
@@ -0,0 +1,5 @@
+import { createContext } from '@custom/preact';
+
+export function aaa() {
+ const context = createContext();
+}
diff --git a/packages/prefresh/tests/fixtures/import-custom-preact/output.js b/packages/prefresh/tests/fixtures/import-custom-preact/output.js
new file mode 100644
index 0000000..b624aa1
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/import-custom-preact/output.js
@@ -0,0 +1,7 @@
+import { createContext } from "@custom/preact";
+//#region virtual:entry.js
+function aaa() {
+ createContext[`1e87a657330148b0$context1`] || (createContext[`1e87a657330148b0$context1`] = createContext());
+}
+//#endregion
+export { aaa };
diff --git a/packages/prefresh/tests/fixtures/import-default/input.js b/packages/prefresh/tests/fixtures/import-default/input.js
new file mode 100644
index 0000000..82da79b
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/import-default/input.js
@@ -0,0 +1,5 @@
+import pp from 'preact';
+
+export function aaa(a, b) {
+ const context = pp.createContext();
+}
diff --git a/packages/prefresh/tests/fixtures/import-default/output.js b/packages/prefresh/tests/fixtures/import-default/output.js
new file mode 100644
index 0000000..e110d23
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/import-default/output.js
@@ -0,0 +1,7 @@
+import pp from "preact";
+//#region virtual:entry.js
+function aaa(a, b) {
+ pp.createContext[`1e87a657330148b0$context1_${a}${b}`] || (pp.createContext[`1e87a657330148b0$context1_${a}${b}`] = pp.createContext());
+}
+//#endregion
+export { aaa };
diff --git a/packages/prefresh/tests/fixtures/import-lib-computed/input.js b/packages/prefresh/tests/fixtures/import-lib-computed/input.js
new file mode 100644
index 0000000..0838740
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/import-lib-computed/input.js
@@ -0,0 +1,5 @@
+import pp from 'preact';
+
+export function aaa(a, b) {
+ const context = pp["createContext"]();
+}
diff --git a/packages/prefresh/tests/fixtures/import-lib-computed/output.js b/packages/prefresh/tests/fixtures/import-lib-computed/output.js
new file mode 100644
index 0000000..77daff9
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/import-lib-computed/output.js
@@ -0,0 +1,7 @@
+import pp from "preact";
+//#region virtual:entry.js
+function aaa(a, b) {
+ pp["createContext"][`1e87a657330148b0$context1_${a}${b}`] || (pp["createContext"][`1e87a657330148b0$context1_${a}${b}`] = pp["createContext"]());
+}
+//#endregion
+export { aaa };
diff --git a/packages/prefresh/tests/fixtures/import-named/input.js b/packages/prefresh/tests/fixtures/import-named/input.js
new file mode 100644
index 0000000..826fcce
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/import-named/input.js
@@ -0,0 +1,5 @@
+import { createContext as cc } from 'preact';
+
+export function aaa() {
+ const context = cc();
+}
diff --git a/packages/prefresh/tests/fixtures/import-named/output.js b/packages/prefresh/tests/fixtures/import-named/output.js
new file mode 100644
index 0000000..2cd52b4
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/import-named/output.js
@@ -0,0 +1,7 @@
+import { createContext } from "preact";
+//#region virtual:entry.js
+function aaa() {
+ createContext[`1e87a657330148b0$context1`] || (createContext[`1e87a657330148b0$context1`] = createContext());
+}
+//#endregion
+export { aaa };
diff --git a/packages/prefresh/tests/fixtures/import-namespace/input.js b/packages/prefresh/tests/fixtures/import-namespace/input.js
new file mode 100644
index 0000000..a2204f7
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/import-namespace/input.js
@@ -0,0 +1,5 @@
+import * as pp from 'preact';
+
+export function aaa() {
+ const context = pp.createContext();
+}
diff --git a/packages/prefresh/tests/fixtures/import-namespace/output.js b/packages/prefresh/tests/fixtures/import-namespace/output.js
new file mode 100644
index 0000000..8f09bb1
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/import-namespace/output.js
@@ -0,0 +1,7 @@
+import * as pp from "preact";
+//#region virtual:entry.js
+function aaa() {
+ pp.createContext[`1e87a657330148b0$context1`] || (pp.createContext[`1e87a657330148b0$context1`] = pp.createContext());
+}
+//#endregion
+export { aaa };
diff --git a/packages/prefresh/tests/fixtures/local/input.js b/packages/prefresh/tests/fixtures/local/input.js
new file mode 100644
index 0000000..403ed7e
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/local/input.js
@@ -0,0 +1,9 @@
+import { createContext } from "preact";
+export function aaa() {
+ function createContext() { }
+ const context = createContext();
+}
+
+export function bbb() {
+ const context = createContext();
+}
diff --git a/packages/prefresh/tests/fixtures/local/output.js b/packages/prefresh/tests/fixtures/local/output.js
new file mode 100644
index 0000000..39ed3b7
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/local/output.js
@@ -0,0 +1,11 @@
+import { createContext } from "preact";
+//#region virtual:entry.js
+function aaa() {
+ function createContext() {}
+ createContext();
+}
+function bbb() {
+ createContext[`1e87a657330148b0$context1`] || (createContext[`1e87a657330148b0$context1`] = createContext());
+}
+//#endregion
+export { aaa, bbb };
diff --git a/packages/prefresh/tests/fixtures/multiple/input.js b/packages/prefresh/tests/fixtures/multiple/input.js
new file mode 100644
index 0000000..0759656
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/multiple/input.js
@@ -0,0 +1,14 @@
+import { createContext as cc } from 'preact';
+import * as ns from 'preact';
+import df from 'preact';
+
+export function aaa(a, b) {
+ cc({});
+ ns.createContext();
+ df.createContext(b);
+ return function bbb(a, b, c) {
+ cc({});
+ ns.createContext();
+ df.createContext(b);
+ }
+}
diff --git a/packages/prefresh/tests/fixtures/multiple/output.js b/packages/prefresh/tests/fixtures/multiple/output.js
new file mode 100644
index 0000000..0b6f760
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/multiple/output.js
@@ -0,0 +1,15 @@
+import * as ns from "preact";
+import df, { createContext } from "preact";
+//#region virtual:entry.js
+function aaa(a, b) {
+ Object.assign(createContext[`1e87a657330148b01_${a}${b}`] || (createContext[`1e87a657330148b01_${a}${b}`] = createContext({})), { __: {} });
+ ns.createContext[`1e87a657330148b02_${a}${b}`] || (ns.createContext[`1e87a657330148b02_${a}${b}`] = ns.createContext());
+ Object.assign(df.createContext[`1e87a657330148b03_${a}${b}`] || (df.createContext[`1e87a657330148b03_${a}${b}`] = df.createContext(b)), { __: b });
+ return function bbb(a, b, c) {
+ Object.assign(createContext[`1e87a657330148b04_${a}${b}${c}`] || (createContext[`1e87a657330148b04_${a}${b}${c}`] = createContext({})), { __: {} });
+ ns.createContext[`1e87a657330148b05_${a}${b}${c}`] || (ns.createContext[`1e87a657330148b05_${a}${b}${c}`] = ns.createContext());
+ Object.assign(df.createContext[`1e87a657330148b06_${a}${b}${c}`] || (df.createContext[`1e87a657330148b06_${a}${b}${c}`] = df.createContext(b)), { __: b });
+ };
+}
+//#endregion
+export { aaa };
diff --git a/packages/prefresh/tests/fixtures/param/input.js b/packages/prefresh/tests/fixtures/param/input.js
new file mode 100644
index 0000000..7d9243b
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/param/input.js
@@ -0,0 +1,5 @@
+import { createContext } from 'preact';
+
+export function aaa(a, b) {
+ const context = createContext();
+}
diff --git a/packages/prefresh/tests/fixtures/param/output.js b/packages/prefresh/tests/fixtures/param/output.js
new file mode 100644
index 0000000..f05a7d9
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/param/output.js
@@ -0,0 +1,7 @@
+import { createContext } from "preact";
+//#region virtual:entry.js
+function aaa(a, b) {
+ createContext[`1e87a657330148b0$context1_${a}${b}`] || (createContext[`1e87a657330148b0$context1_${a}${b}`] = createContext());
+}
+//#endregion
+export { aaa };
diff --git a/packages/prefresh/tests/fixtures/value/input.js b/packages/prefresh/tests/fixtures/value/input.js
new file mode 100644
index 0000000..977148c
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/value/input.js
@@ -0,0 +1,5 @@
+import { createContext } from 'preact';
+
+export function aaa(a, b) {
+ const context = createContext({});
+}
diff --git a/packages/prefresh/tests/fixtures/value/output.js b/packages/prefresh/tests/fixtures/value/output.js
new file mode 100644
index 0000000..3d6593a
--- /dev/null
+++ b/packages/prefresh/tests/fixtures/value/output.js
@@ -0,0 +1,7 @@
+import { createContext } from "preact";
+//#region virtual:entry.js
+function aaa(a, b) {
+ Object.assign(createContext[`1e87a657330148b0$context1_${a}${b}`] || (createContext[`1e87a657330148b0$context1_${a}${b}`] = createContext({})), { __: {} });
+}
+//#endregion
+export { aaa };
diff --git a/packages/prefresh/tests/transform.test.ts b/packages/prefresh/tests/transform.test.ts
new file mode 100644
index 0000000..4f263f8
--- /dev/null
+++ b/packages/prefresh/tests/transform.test.ts
@@ -0,0 +1,72 @@
+import { describe, it, expect } from 'vitest'
+import { rolldown } from 'rolldown'
+import prefreshPlugin from '../src/index.ts'
+import { globSync } from 'tinyglobby'
+import { readFileSync, existsSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import type { PrefreshPluginOptions } from '../src/types.ts'
+
+const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
+
+// Get all fixture directories (input.js files)
+const fixturePaths = globSync(['*/input.js', '**/*/input.js'], {
+ cwd: fixturesDir,
+})
+
+describe('fixtures', () => {
+ for (const inputPath of fixturePaths) {
+ const fixtureName = dirname(inputPath)
+ const fullInputPath = join(fixturesDir, inputPath)
+ const input = readFileSync(fullInputPath, 'utf-8')
+
+ // Check for config.json
+ const configPath = join(fixturesDir, fixtureName, 'config.json')
+ const config: PrefreshPluginOptions = existsSync(configPath)
+ ? JSON.parse(readFileSync(configPath, 'utf-8'))
+ : {}
+
+ it(fixtureName, async () => {
+ const result = await transform(input, config, fullInputPath)
+ await expect(result).toMatchFileSnapshot(join(fixturesDir, fixtureName, 'output.js'))
+ })
+ }
+})
+
+async function transform(
+ code: string,
+ options: PrefreshPluginOptions,
+ filename = 'virtual:entry.js',
+): Promise {
+ const ext = filename.match(/\.[jt]sx?$/)?.[0] ?? '.js'
+ const virtualEntry = `virtual:entry${ext}`
+
+ const build = await rolldown({
+ input: virtualEntry,
+ plugins: [
+ {
+ name: 'virtual',
+ resolveId(id) {
+ if (id === virtualEntry) return id
+ // Mark all other imports as external
+ return { id, external: true }
+ },
+ load(id) {
+ if (id === virtualEntry) return code
+ },
+ },
+ prefreshPlugin({ ...options, enabled: true }),
+ ],
+ })
+
+ const { output } = await build.generate({ format: 'esm' })
+ return stripRolldownRuntime(output[0].code)
+}
+
+function stripRolldownRuntime(code: string): string {
+ // Replace rolldown runtime regions with a stable comment
+ return code.replace(
+ /\/\/#region \\0rolldown\/runtime\.js[\s\S]*?\/\/#endregion\n*/g,
+ '// [rolldown runtime elided]\n',
+ )
+}
diff --git a/packages/prefresh/tsdown.config.ts b/packages/prefresh/tsdown.config.ts
new file mode 100644
index 0000000..a3a2720
--- /dev/null
+++ b/packages/prefresh/tsdown.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'tsdown'
+
+export default defineConfig({
+ entry: './src/index.ts',
+ dts: {
+ tsconfig: '../../tsconfig.common.json',
+ tsgo: true,
+ },
+})
diff --git a/packages/prefresh/vitest.config.ts b/packages/prefresh/vitest.config.ts
new file mode 100644
index 0000000..60761a3
--- /dev/null
+++ b/packages/prefresh/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ name: 'prefresh',
+ },
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 160eddb..141b299 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -72,6 +72,25 @@ importers:
specifier: 8.0.0
version: 8.0.0(@types/node@24.12.0)(esbuild@0.27.3)
+ examples/prefresh:
+ dependencies:
+ preact:
+ specifier: ^10.29.0
+ version: 10.29.0
+ devDependencies:
+ '@prefresh/core':
+ specifier: ^1.5.9
+ version: 1.5.9(preact@10.29.0)
+ '@prefresh/utils':
+ specifier: ^1.2.1
+ version: 1.2.1
+ '@rolldown/plugin-prefresh':
+ specifier: workspace:*
+ version: link:../../packages/prefresh
+ vite:
+ specifier: ^8.0.0
+ version: 8.0.0(@types/node@24.12.0)(esbuild@0.27.3)
+
internal-packages/benchmark-utils:
devDependencies:
'@oxc-node/core':
@@ -102,6 +121,9 @@ importers:
'@swc/plugin-emotion':
specifier: ^14.7.0
version: 14.7.0
+ '@swc/plugin-prefresh':
+ specifier: ^12.7.0
+ version: 12.7.0
rolldown:
specifier: ^1.0.0-rc.9
version: 1.0.0-rc.9
@@ -220,6 +242,65 @@ importers:
specifier: ^1.0.0-rc.9
version: 1.0.0-rc.9
+ packages/prefresh:
+ dependencies:
+ '@rolldown/oxc-unshadowed-visitor':
+ specifier: workspace:*
+ version: link:../../internal-packages/oxc-unshadowed-visitor
+ rolldown-string:
+ specifier: ^0.3.0
+ version: 0.3.0(rolldown@1.0.0-rc.9)
+ devDependencies:
+ rolldown:
+ specifier: ^1.0.0-rc.9
+ version: 1.0.0-rc.9
+ tinyglobby:
+ specifier: ^0.2.15
+ version: 0.2.15
+ vite:
+ specifier: ^8.0.0
+ version: 8.0.0(@types/node@24.12.0)(esbuild@0.27.3)
+
+ packages/prefresh/benchmark:
+ dependencies:
+ preact:
+ specifier: ^10.29.0
+ version: 10.29.0
+ devDependencies:
+ '@babel/core':
+ specifier: ^7.29.0
+ version: 7.29.0
+ '@oxc-node/cli':
+ specifier: ^0.0.35
+ version: 0.0.35
+ '@prefresh/babel-plugin':
+ specifier: ^0.5.3
+ version: 0.5.3
+ '@rolldown/benchmark-utils':
+ specifier: workspace:*
+ version: link:../../../internal-packages/benchmark-utils
+ '@rolldown/plugin-babel':
+ specifier: file:../../babel
+ version: file:packages/babel(@babel/core@7.29.0)(@babel/plugin-transform-runtime@8.0.0-rc.2(@babel/core@7.29.0))(@babel/runtime@8.0.0-rc.2)(rolldown@1.0.0-rc.9)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3))
+ '@rolldown/plugin-prefresh':
+ specifier: workspace:*
+ version: link:..
+ '@rollup/plugin-swc':
+ specifier: ^0.4.0
+ version: 0.4.0(@swc/core@1.15.18)
+ '@swc/core':
+ specifier: ^1.15.18
+ version: 1.15.18
+ '@swc/plugin-prefresh':
+ specifier: ^12.7.0
+ version: 12.7.0
+ '@types/node':
+ specifier: ^24.10.13
+ version: 24.12.0
+ rolldown:
+ specifier: ^1.0.0-rc.9
+ version: 1.0.0-rc.9
+
scripts:
devDependencies:
'@vitejs/release-scripts':
@@ -1186,6 +1267,17 @@ packages:
cpu: [x64]
os: [win32]
+ '@prefresh/babel-plugin@0.5.3':
+ resolution: {integrity: sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==}
+
+ '@prefresh/core@1.5.9':
+ resolution: {integrity: sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==}
+ peerDependencies:
+ preact: ^10.0.0 || ^11.0.0-0
+
+ '@prefresh/utils@1.2.1':
+ resolution: {integrity: sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==}
+
'@publint/pack@0.1.4':
resolution: {integrity: sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ==}
engines: {node: '>=18'}
@@ -1444,6 +1536,9 @@ packages:
'@swc/plugin-emotion@14.7.0':
resolution: {integrity: sha512-RwYrsxia8GKh2qLHWwymcfCeP6C5gAkssB2YtBRhP/qlKCxXYfv808buEXkCYvyGIY+bN3XziKXCuAi+waA5pQ==}
+ '@swc/plugin-prefresh@12.7.0':
+ resolution: {integrity: sha512-1d+YWDPdeHcxbK6WXwm+TYMDwKSsSzf6OoqFrBpUujr3XvE2ydK+egocGJS7Z8bhwIUD4bHst6CqZVO+v32o/Q==}
+
'@swc/types@0.1.25':
resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==}
@@ -2116,6 +2211,9 @@ packages:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
+ preact@10.29.0:
+ resolution: {integrity: sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==}
+
pretty-ms@9.3.0:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
@@ -3239,6 +3337,14 @@ snapshots:
'@oxlint/binding-win32-x64-msvc@1.55.0':
optional: true
+ '@prefresh/babel-plugin@0.5.3': {}
+
+ '@prefresh/core@1.5.9(preact@10.29.0)':
+ dependencies:
+ preact: 10.29.0
+
+ '@prefresh/utils@1.2.1': {}
+
'@publint/pack@0.1.4': {}
'@quansync/fs@1.0.0':
@@ -3396,6 +3502,10 @@ snapshots:
dependencies:
'@swc/counter': 0.1.3
+ '@swc/plugin-prefresh@12.7.0':
+ dependencies:
+ '@swc/counter': 0.1.3
+
'@swc/types@0.1.25':
dependencies:
'@swc/counter': 0.1.3
@@ -4076,6 +4186,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ preact@10.29.0: {}
+
pretty-ms@9.3.0:
dependencies:
parse-ms: 4.0.0
diff --git a/tsconfig.json b/tsconfig.json
index b67e615..26fdc67 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,4 +1,9 @@
{
- "references": [{ "path": "./tsconfig.common.json" }, { "path": "./examples/tsconfig.json" }],
+ "references": [
+ { "path": "./tsconfig.common.json" },
+ { "path": "./examples/tsconfig.root.json" },
+ { "path": "./examples/emotion/tsconfig.json" },
+ { "path": "./examples/prefresh/tsconfig.json" }
+ ],
"include": []
}