From a6e390153edc8e7ddb171df6fd07de35a3795f75 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Sun, 15 Mar 2026 05:51:09 +0900 Subject: [PATCH 1/4] feat(prefresh): add prefresh plugin --- packages/prefresh/README.md | 57 ++++ packages/prefresh/package.json | 62 ++++ packages/prefresh/src/index.ts | 287 ++++++++++++++++++ packages/prefresh/src/types.ts | 13 + .../prefresh/tests/fixtures/basic/input.js | 5 + .../prefresh/tests/fixtures/basic/output.js | 7 + .../fixtures/import-custom-preact/config.json | 3 + .../fixtures/import-custom-preact/input.js | 5 + .../fixtures/import-custom-preact/output.js | 7 + .../tests/fixtures/import-default/input.js | 5 + .../tests/fixtures/import-default/output.js | 7 + .../fixtures/import-lib-computed/input.js | 5 + .../fixtures/import-lib-computed/output.js | 7 + .../tests/fixtures/import-named/input.js | 5 + .../tests/fixtures/import-named/output.js | 7 + .../tests/fixtures/import-namespace/input.js | 5 + .../tests/fixtures/import-namespace/output.js | 7 + .../prefresh/tests/fixtures/local/input.js | 9 + .../prefresh/tests/fixtures/local/output.js | 11 + .../prefresh/tests/fixtures/multiple/input.js | 14 + .../tests/fixtures/multiple/output.js | 15 + .../prefresh/tests/fixtures/param/input.js | 5 + .../prefresh/tests/fixtures/param/output.js | 7 + .../prefresh/tests/fixtures/value/input.js | 5 + .../prefresh/tests/fixtures/value/output.js | 7 + packages/prefresh/tests/transform.test.ts | 72 +++++ packages/prefresh/tsdown.config.ts | 9 + packages/prefresh/vitest.config.ts | 7 + pnpm-lock.yaml | 19 ++ 29 files changed, 674 insertions(+) create mode 100644 packages/prefresh/README.md create mode 100644 packages/prefresh/package.json create mode 100644 packages/prefresh/src/index.ts create mode 100644 packages/prefresh/src/types.ts create mode 100644 packages/prefresh/tests/fixtures/basic/input.js create mode 100644 packages/prefresh/tests/fixtures/basic/output.js create mode 100644 packages/prefresh/tests/fixtures/import-custom-preact/config.json create mode 100644 packages/prefresh/tests/fixtures/import-custom-preact/input.js create mode 100644 packages/prefresh/tests/fixtures/import-custom-preact/output.js create mode 100644 packages/prefresh/tests/fixtures/import-default/input.js create mode 100644 packages/prefresh/tests/fixtures/import-default/output.js create mode 100644 packages/prefresh/tests/fixtures/import-lib-computed/input.js create mode 100644 packages/prefresh/tests/fixtures/import-lib-computed/output.js create mode 100644 packages/prefresh/tests/fixtures/import-named/input.js create mode 100644 packages/prefresh/tests/fixtures/import-named/output.js create mode 100644 packages/prefresh/tests/fixtures/import-namespace/input.js create mode 100644 packages/prefresh/tests/fixtures/import-namespace/output.js create mode 100644 packages/prefresh/tests/fixtures/local/input.js create mode 100644 packages/prefresh/tests/fixtures/local/output.js create mode 100644 packages/prefresh/tests/fixtures/multiple/input.js create mode 100644 packages/prefresh/tests/fixtures/multiple/output.js create mode 100644 packages/prefresh/tests/fixtures/param/input.js create mode 100644 packages/prefresh/tests/fixtures/param/output.js create mode 100644 packages/prefresh/tests/fixtures/value/input.js create mode 100644 packages/prefresh/tests/fixtures/value/output.js create mode 100644 packages/prefresh/tests/transform.test.ts create mode 100644 packages/prefresh/tsdown.config.ts create mode 100644 packages/prefresh/vitest.config.ts diff --git a/packages/prefresh/README.md b/packages/prefresh/README.md new file mode 100644 index 0000000..99765f6 --- /dev/null +++ b/packages/prefresh/README.md @@ -0,0 +1,57 @@ +# @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`. + +## 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/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..e8e2f17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -220,6 +220,25 @@ 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) + scripts: devDependencies: '@vitejs/release-scripts': From 44d362310cd36037e9934a21d1ba20df6c7e0a18 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:01:53 +0900 Subject: [PATCH 2/4] test(prefresh): add e2e example --- examples/emotion/tsconfig.json | 3 + examples/prefresh/index.html | 12 ++ examples/prefresh/package.json | 19 ++++ examples/prefresh/prefresh.test.ts | 23 ++++ examples/prefresh/src/App.tsx | 27 +++++ examples/prefresh/src/main.tsx | 4 + examples/prefresh/tsconfig.json | 6 + examples/prefresh/vite.config.ts | 104 ++++++++++++++++++ .../{tsconfig.json => tsconfig.base.json} | 0 examples/tsconfig.root.json | 4 + examples/vitestGlobalSetup.ts | 4 + pnpm-lock.yaml | 38 +++++++ tsconfig.json | 7 +- 13 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 examples/emotion/tsconfig.json create mode 100644 examples/prefresh/index.html create mode 100644 examples/prefresh/package.json create mode 100644 examples/prefresh/prefresh.test.ts create mode 100644 examples/prefresh/src/App.tsx create mode 100644 examples/prefresh/src/main.tsx create mode 100644 examples/prefresh/tsconfig.json create mode 100644 examples/prefresh/vite.config.ts rename examples/{tsconfig.json => tsconfig.base.json} (100%) create mode 100644 examples/tsconfig.root.json 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/pnpm-lock.yaml b/pnpm-lock.yaml index e8e2f17..74f752d 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': @@ -1205,6 +1224,14 @@ packages: cpu: [x64] os: [win32] + '@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'} @@ -2135,6 +2162,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'} @@ -3258,6 +3288,12 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.55.0': optional: true + '@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': @@ -4095,6 +4131,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": [] } From 9a934d593fa2695f3ae56ab594f22205edc48a9c Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:24:31 +0900 Subject: [PATCH 3/4] chore(prefresh): add benchmark --- packages/prefresh/README.md | 23 ++ packages/prefresh/benchmark/.gitignore | 8 + .../benchmark/bench/prefresh.bench.ts | 52 ++++ packages/prefresh/benchmark/configs/babel.ts | 27 ++ packages/prefresh/benchmark/configs/custom.ts | 17 ++ packages/prefresh/benchmark/configs/swc.ts | 40 +++ packages/prefresh/benchmark/package.json | 29 ++ .../benchmark/scripts/generate-app.ts | 265 ++++++++++++++++++ .../prefresh/benchmark/shared-app/src/App.tsx | 24 ++ .../benchmark/shared-app/src/index.tsx | 13 + packages/prefresh/benchmark/tsconfig.json | 18 ++ packages/prefresh/benchmark/vitest.config.ts | 7 + pnpm-lock.yaml | 52 ++++ 13 files changed, 575 insertions(+) create mode 100644 packages/prefresh/benchmark/.gitignore create mode 100644 packages/prefresh/benchmark/bench/prefresh.bench.ts create mode 100644 packages/prefresh/benchmark/configs/babel.ts create mode 100644 packages/prefresh/benchmark/configs/custom.ts create mode 100644 packages/prefresh/benchmark/configs/swc.ts create mode 100644 packages/prefresh/benchmark/package.json create mode 100644 packages/prefresh/benchmark/scripts/generate-app.ts create mode 100644 packages/prefresh/benchmark/shared-app/src/App.tsx create mode 100644 packages/prefresh/benchmark/shared-app/src/index.tsx create mode 100644 packages/prefresh/benchmark/tsconfig.json create mode 100644 packages/prefresh/benchmark/vitest.config.ts diff --git a/packages/prefresh/README.md b/packages/prefresh/README.md index 99765f6..285de5f 100644 --- a/packages/prefresh/README.md +++ b/packages/prefresh/README.md @@ -48,6 +48,29 @@ prefresh({ 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 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/pnpm-lock.yaml b/pnpm-lock.yaml index 74f752d..c2464ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,6 +258,46 @@ importers: 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': @@ -1224,6 +1264,9 @@ 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: @@ -1490,6 +1533,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==} @@ -3288,6 +3334,8 @@ 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 @@ -3451,6 +3499,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 From 06336172a9aba7e5b6d48b0a1651b4f312ec569f Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:03:02 +0900 Subject: [PATCH 4/4] feat(swc-output-gen): add prefresh plugin --- internal-packages/swc-output-gen/package.json | 1 + internal-packages/swc-output-gen/src/plugin-registry.ts | 4 ++++ pnpm-lock.yaml | 3 +++ 3 files changed, 8 insertions(+) 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/pnpm-lock.yaml b/pnpm-lock.yaml index c2464ca..141b299 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,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