diff --git a/packages/remix-hmr/README.md b/packages/remix-hmr/README.md
new file mode 100644
index 000000000..0493a40a4
--- /dev/null
+++ b/packages/remix-hmr/README.md
@@ -0,0 +1,14 @@
+# remix-hmr
+
+Simple component HMR runtime and transform for [Remix 3](https://github.com/remix-run/remix), ported from [tiny-refresh](https://github.com/hi-ogawa/js-utils/tree/main/packages/tiny-refresh)
+
+## Usage
+
+```js
+import { defineConfig } from "vite";
+import remixHmr from "@hiogawa/remix-hmr/vite";
+
+export default defineConfig({
+ plugins: [remixHmr()],
+});
+```
diff --git a/packages/remix-hmr/examples/basic/index.html b/packages/remix-hmr/examples/basic/index.html
new file mode 100644
index 000000000..e42cc168f
--- /dev/null
+++ b/packages/remix-hmr/examples/basic/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+ tiny-refresh demo
+
+
+
+
+
+
+
diff --git a/packages/remix-hmr/examples/basic/package.json b/packages/remix-hmr/examples/basic/package.json
new file mode 100644
index 000000000..f1222f9c9
--- /dev/null
+++ b/packages/remix-hmr/examples/basic/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@hiogawa/remix-hmr-examples-basic",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev"
+ },
+ "devDependencies": {
+ "@hiogawa/remix-hmr": "workspace:*",
+ "@remix-run/dom": "0.0.0-experimental-remix-jam.6",
+ "@remix-run/events": "0.0.0-experimental-remix-jam.5",
+ "vite": "^7.1.5"
+ }
+}
diff --git a/packages/remix-hmr/examples/basic/public/favicon.ico b/packages/remix-hmr/examples/basic/public/favicon.ico
new file mode 100644
index 000000000..519b939a0
Binary files /dev/null and b/packages/remix-hmr/examples/basic/public/favicon.ico differ
diff --git a/packages/remix-hmr/examples/basic/src/index.tsx b/packages/remix-hmr/examples/basic/src/index.tsx
new file mode 100644
index 000000000..77e65111f
--- /dev/null
+++ b/packages/remix-hmr/examples/basic/src/index.tsx
@@ -0,0 +1,9 @@
+import { createRoot } from "@remix-run/dom";
+import { Root } from "./root";
+
+function main() {
+ const el = document.getElementById("root")!;
+ createRoot(el).render();
+}
+
+main();
diff --git a/packages/remix-hmr/examples/basic/src/root.tsx b/packages/remix-hmr/examples/basic/src/root.tsx
new file mode 100644
index 000000000..2dd1bc5f9
--- /dev/null
+++ b/packages/remix-hmr/examples/basic/src/root.tsx
@@ -0,0 +1,41 @@
+import type { Remix } from "@remix-run/dom";
+import { dom } from "@remix-run/events";
+
+export function Root() {
+ return (
+
+
+
+
+ );
+}
+
+function Counter(this: Remix.Handle) {
+ let count = 0;
+ const setCount = (v: number) => {
+ count = v;
+ this.update();
+ };
+
+ return () => {
+ return (
+
+
Count: {count}
+
+
+
+ );
+ };
+}
diff --git a/packages/remix-hmr/examples/basic/tsconfig.json b/packages/remix-hmr/examples/basic/tsconfig.json
new file mode 100644
index 000000000..fa59c3383
--- /dev/null
+++ b/packages/remix-hmr/examples/basic/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../../../tsconfig.base.json",
+ "include": ["src", "*.ts"],
+ "compilerOptions": {
+ "types": ["vite/client"],
+ "jsx": "react-jsx",
+ "jsxImportSource": "@remix-run/dom"
+ }
+}
diff --git a/packages/remix-hmr/examples/basic/vite.config.ts b/packages/remix-hmr/examples/basic/vite.config.ts
new file mode 100644
index 000000000..71be488ca
--- /dev/null
+++ b/packages/remix-hmr/examples/basic/vite.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "vite";
+
+// import remixHmr from "@hiogawa/remix-hmr/vite";
+import remixHmr from "../../dist/vite.js";
+
+export default defineConfig({
+ clearScreen: false,
+ plugins: [remixHmr()],
+});
diff --git a/packages/remix-hmr/package.json b/packages/remix-hmr/package.json
new file mode 100644
index 000000000..bd60a446c
--- /dev/null
+++ b/packages/remix-hmr/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@hiogawa/remix-hmr",
+ "version": "0.0.0",
+ "homepage": "https://github.com/hi-ogawa/vite-plugins/tree/main/packages/vite-plugin-remix-hmr",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/hi-ogawa/vite-plugins.git",
+ "directory": "packages/remix-hmr"
+ },
+ "license": "MIT",
+ "type": "module",
+ "exports": {
+ ".": "./dist/index.js",
+ "./vite": "./dist/vite.js"
+ },
+ "files": ["dist"],
+ "scripts": {
+ "dev": "tsdown --watch src",
+ "build": "tsdown",
+ "test": "vitest"
+ },
+ "dependencies": {
+ "magic-string": "^0.30.17"
+ },
+ "devDependencies": {
+ "@remix-run/dom": "0.0.0-experimental-remix-jam.6"
+ },
+ "peerDependencies": {
+ "@remix-run/dom": "*",
+ "vite": "*"
+ }
+}
diff --git a/packages/remix-hmr/src/index.ts b/packages/remix-hmr/src/index.ts
new file mode 100644
index 000000000..cb0ff5c3b
--- /dev/null
+++ b/packages/remix-hmr/src/index.ts
@@ -0,0 +1 @@
+export {};
diff --git a/packages/remix-hmr/src/runtime.ts b/packages/remix-hmr/src/runtime.ts
new file mode 100644
index 000000000..fbfccf622
--- /dev/null
+++ b/packages/remix-hmr/src/runtime.ts
@@ -0,0 +1,116 @@
+import type { Remix } from "@remix-run/dom";
+import { jsx } from "@remix-run/dom/jsx-runtime";
+import type { RefreshRuntimeOptions } from "./transform";
+
+const HMR_MANAGER_KEY = Symbol.for("remix-hmr-manager");
+
+export interface ViteHot {
+ accept: (onNewModule: (newModule?: unknown) => void) => void;
+ invalidate: (message?: string) => void;
+ data: HotData;
+}
+
+type HotData = {
+ [HMR_MANAGER_KEY]?: Manager;
+};
+
+interface ProxyEntry {
+ Component: Remix.Component;
+ listeners: Set<() => void>;
+}
+
+interface ComponentEntry {
+ Component: Remix.Component;
+ key: string;
+}
+
+// Manager is singleton per file
+class Manager {
+ public proxyMap = new Map();
+ public componentMap = new Map();
+ public setup = () => {};
+
+ constructor(public options: RefreshRuntimeOptions) {}
+
+ wrap(name: string, Component: FC, key: string): FC {
+ this.componentMap.set(name, { Component, key });
+ let proxy = this.proxyMap.get(name);
+ if (!proxy) {
+ proxy = createProxyComponent(this, name);
+ this.proxyMap.set(name, proxy);
+ }
+ return proxy.Component;
+ }
+
+ patch() {
+ const componentNames = new Set([
+ ...this.proxyMap.keys(),
+ ...this.componentMap.keys(),
+ ]);
+ for (const name of componentNames) {
+ const proxy = this.proxyMap.get(name);
+ const current = this.componentMap.get(name);
+ if (!proxy || !current) {
+ return false;
+ }
+ if (this.options.debug) {
+ console.debug(
+ `[remix-hmr] refresh '${name}' (key = ${current.key}, listeners.size = ${proxy.listeners.size})`,
+ );
+ }
+ for (const listener of proxy.listeners) {
+ listener();
+ }
+ }
+ return true;
+ }
+}
+
+function createProxyComponent(manager: Manager, name: string): ProxyEntry {
+ const listeners = new Set<() => void>();
+
+ // TODO: how to preserve state?
+ // For example, Vue SFC compiles "setup" part and "template" part as separate function,
+ // then variables from "setup" are passed "template" render function arguments.
+ // For Remix 3, state is entirely managed through closure scope,
+ // so there's no way to preserve original "setup" state for updated "render" function.
+
+ const ProxyComponent: Remix.Component = function (this) {
+ listeners.add(() => this.update());
+
+ // TODO: do we call setup for the first time and somehow patch it up later?
+ // const data = manager.componentMap.get(name)!;
+ // let result = data.Component.apply(this, [props]);
+ // if (typeof result !== 'function') {
+ // return () => result;
+ // }
+
+ return (props) => {
+ const data = manager.componentMap.get(name)!;
+ // TODO: this obviously remounts entire component since updated `Component` has a new identity.
+ return jsx(data.Component as any, props);
+ };
+ };
+
+ Object.defineProperty(ProxyComponent, "name", { value: `${name}@hmr` });
+
+ return { Component: ProxyComponent, listeners };
+}
+
+//
+// HMR API integration
+//
+
+export function initialize(hot: ViteHot, options: RefreshRuntimeOptions) {
+ const manager = (hot.data[HMR_MANAGER_KEY] ??= new Manager(options));
+
+ // https://vitejs.dev/guide/api-hmr.html#hot-accept-cb
+ hot.accept((newModule) => {
+ const ok = newModule && manager.patch();
+ if (!ok) {
+ hot.invalidate();
+ }
+ });
+
+ return manager;
+}
diff --git a/packages/remix-hmr/src/transform.test.ts b/packages/remix-hmr/src/transform.test.ts
new file mode 100644
index 000000000..4dc8f4523
--- /dev/null
+++ b/packages/remix-hmr/src/transform.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+import { transform } from "./transform";
+
+describe(transform, () => {
+ it("basic", async () => {
+ const input = /* js */ `\
+
+export default function FnDefault() {}
+
+export let FnLet = () => {
+ useState();
+ useEffect;
+ useRef();
+ // useCallback();
+ return "hello";
+}
+
+export const FnConst = () => {}
+
+const FnNonExport = () => {}
+
+function notCapitalFn() {}
+
+const NotFn = "hello";
+
+// TODO
+// export const FnExpr = function() {}
+// export const NotFn2 = "hello";
+`;
+ expect(
+ await transform(input, {
+ mode: "vite",
+ debug: false,
+ }),
+ ).toMatchInlineSnapshot(`
+ "
+ export default function FnDefault() {}
+
+ export let FnLet = () => {
+ useState();
+ useEffect;
+ useRef();
+ // useCallback();
+ return "hello";
+ }
+
+ export let FnConst = () => {}
+
+ let FnNonExport = () => {}
+
+ function notCapitalFn() {}
+
+ let NotFn = "hello";
+
+ // TODO
+ // export const FnExpr = function() {}
+ // export const NotFn2 = "hello";
+
+ ;import * as $$refresh from "virtual:remix-hmr-runtime";
+ if (import.meta.hot) {
+ (() => import.meta.hot.accept());
+ const $$manager = $$refresh.initialize(
+ import.meta.hot,
+ undefined,
+ {"mode":"vite","debug":false}
+ );
+
+ FnDefault = $$manager.wrap("FnDefault", FnDefault, "");
+ FnLet = $$manager.wrap("FnLet", FnLet, "useState/useRef/useCallback");
+ FnConst = $$manager.wrap("FnConst", FnConst, "");
+ FnNonExport = $$manager.wrap("FnNonExport", FnNonExport, "");
+
+ $$manager.setup();
+ }
+ "
+ `);
+ });
+});
diff --git a/packages/remix-hmr/src/transform.ts b/packages/remix-hmr/src/transform.ts
new file mode 100644
index 000000000..b8271c714
--- /dev/null
+++ b/packages/remix-hmr/src/transform.ts
@@ -0,0 +1,211 @@
+import type * as estree from "estree";
+import { parseAstAsync } from "vite";
+
+export interface TransformOptions {
+ mode: "vite" | "webpack";
+ debug: boolean;
+}
+
+export type RefreshRuntimeOptions = Pick;
+
+export async function transform(code: string, options: TransformOptions) {
+ const result = await analyzeCode(code);
+ if (result.errors.length || result.entries.length === 0) {
+ return;
+ }
+ const hot = "import.meta.hot";
+ const wrap = result.entries
+ .map((e) => {
+ const key = JSON.stringify(e.hooks.join("/"));
+ return ` ${e.id} = $$manager.wrap("${e.id}", ${e.id}, ${key});\n`;
+ })
+ .join("");
+ const footer = `
+;import * as $$refresh from "virtual:remix-hmr-runtime";
+if (import.meta.hot) {
+ (() => import.meta.hot.accept());
+ const $$manager = $$refresh.initialize(
+ ${hot},
+ ${JSON.stringify(options)}
+ );
+
+${wrap}
+ $$manager.setup();
+}
+`;
+ // no need to manipulate sourcemap since transform only appends
+ return result.outCode + footer;
+}
+
+//
+// extract component declarations
+//
+
+// extend types for rollup ast with node position
+declare module "estree" {
+ interface BaseNode {
+ start: number;
+ end: number;
+ }
+}
+
+type ParsedEntry = {
+ id: string;
+ hooks: string[];
+};
+
+const HOOK_CALL_RE = /\b(use\w*)\s*\(/g;
+const COMPONENT_RE = /^[A-Z]/;
+
+async function analyzeCode(code: string) {
+ const ast = await parseAstAsync(code);
+ const errors: unknown[] = [];
+ const entries: ParsedEntry[] = [];
+
+ // replace "export const" with "export let"
+ let outCode = code;
+
+ // loop exports
+ // https://github.com/hi-ogawa/vite-plugins/blob/243064edcf80429be13bee81c0dc1d7bea670f4b/packages/react-server/src/plugin/ast-utils.ts#L14
+ for (const node of ast.body) {
+ // named exports
+ if (node.type === "ExportNamedDeclaration") {
+ if (node.declaration) {
+ if (node.declaration.type === "FunctionDeclaration") {
+ /**
+ * export function foo() {}
+ */
+ if (COMPONENT_RE.test(node.declaration.id.name)) {
+ entries.push({
+ id: node.declaration.id.name,
+ hooks: analyzeFunction(code, node.declaration).hooks,
+ });
+ }
+ } else if (node.declaration.type === "VariableDeclaration") {
+ /**
+ * export const foo = 1, bar = 2
+ */
+ if (node.declaration.kind === "const") {
+ const start = node.declaration.start;
+ outCode = replaceCode(outCode, start, start + 5, "let ");
+ }
+ for (const decl of node.declaration.declarations) {
+ if (
+ decl.id.type === "Identifier" &&
+ decl.init &&
+ decl.init.type === "ArrowFunctionExpression" &&
+ COMPONENT_RE.test(decl.id.name)
+ ) {
+ entries.push({
+ id: decl.id.name,
+ hooks: analyzeFunction(code, decl.init).hooks,
+ });
+ } else {
+ errors.push(decl);
+ }
+ }
+ } else {
+ errors.push(node);
+ }
+ } else {
+ /**
+ * export { foo, bar } from './foo'
+ * export { foo, bar as car }
+ */
+ errors.push(node);
+ }
+ }
+
+ // default export
+ if (node.type === "ExportDefaultDeclaration") {
+ /**
+ * export default function foo() {}
+ */
+ if (
+ node.declaration.type === "FunctionDeclaration" &&
+ node.declaration.id
+ ) {
+ entries.push({
+ id: node.declaration.id.name,
+ hooks: analyzeFunction(code, node.declaration).hooks,
+ });
+ } else {
+ errors.push(node);
+ }
+ }
+
+ /**
+ * export * from './foo'
+ */
+ if (node.type === "ExportAllDeclaration") {
+ errors.push(node);
+ }
+
+ /**
+ * function foo() {}
+ */
+ if (
+ node.type === "FunctionDeclaration" &&
+ COMPONENT_RE.test(node.id.name)
+ ) {
+ entries.push({
+ id: node.id.name,
+ hooks: analyzeFunction(code, node).hooks,
+ });
+ }
+
+ /**
+ * const foo = 1, bar = 2
+ */
+ if (node.type === "VariableDeclaration") {
+ if (node.kind === "const") {
+ const start = node.start;
+ outCode = replaceCode(outCode, start, start + 5, "let ");
+ }
+ for (const decl of node.declarations) {
+ // TODO: FunctionExpression
+ if (
+ decl.id.type === "Identifier" &&
+ decl.init &&
+ decl.init.type === "ArrowFunctionExpression" &&
+ COMPONENT_RE.test(decl.id.name)
+ ) {
+ entries.push({
+ id: decl.id.name,
+ hooks: analyzeFunction(code, decl.init).hooks,
+ });
+ }
+ }
+ }
+ }
+
+ return {
+ entries,
+ outCode,
+ errors,
+ };
+}
+
+function analyzeFunction(
+ code: string,
+ node:
+ | estree.FunctionDeclaration
+ | estree.ArrowFunctionExpression
+ | estree.MaybeNamedFunctionDeclaration,
+) {
+ // we could do this runtime via `fn.toString()`,
+ // but that will impact performance on each render.
+ const bodyCode = code.slice(node.body.start, node.body.end);
+ const matches = bodyCode.matchAll(HOOK_CALL_RE);
+ const hooks = [...matches].map((m) => m[1]!);
+ return { hooks };
+}
+
+function replaceCode(
+ code: string,
+ start: number,
+ end: number,
+ content: string,
+) {
+ return code.slice(0, start) + content + code.slice(end);
+}
diff --git a/packages/remix-hmr/src/vite.ts b/packages/remix-hmr/src/vite.ts
new file mode 100644
index 000000000..ea475d2c1
--- /dev/null
+++ b/packages/remix-hmr/src/vite.ts
@@ -0,0 +1,34 @@
+import { type FilterPattern, type Plugin, createFilter } from "vite";
+import { transform } from "./transform";
+
+export default function vitePluginRemixHmr(options?: {
+ include?: FilterPattern;
+ exclude?: FilterPattern;
+}): Plugin {
+ const filter = createFilter(
+ options?.include ?? /\.[tj]sx$/,
+ options?.exclude ?? /\/node_modules\//,
+ );
+ return {
+ name: "remix-hmr",
+ apply: "serve",
+ applyToEnvironment: (environment) => environment.name === "client",
+ transform: {
+ handler(code, id) {
+ if (filter(id)) {
+ return transform(code, {
+ mode: "vite",
+ debug: true,
+ });
+ }
+ },
+ },
+ resolveId: {
+ handler(source) {
+ if (source === "virtual:remix-hmr-runtime") {
+ return this.resolve("./runtime.js", import.meta.filename);
+ }
+ },
+ },
+ };
+}
diff --git a/packages/remix-hmr/tsconfig.json b/packages/remix-hmr/tsconfig.json
new file mode 100644
index 000000000..d8a77e69e
--- /dev/null
+++ b/packages/remix-hmr/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src", "*.ts"],
+ "compilerOptions": {
+ "jsx": "react-jsx"
+ }
+}
diff --git a/packages/remix-hmr/tsdown.config.ts b/packages/remix-hmr/tsdown.config.ts
new file mode 100644
index 000000000..bd3362722
--- /dev/null
+++ b/packages/remix-hmr/tsdown.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from "tsdown";
+
+export default defineConfig({
+ entry: ["src/index.ts", "src/vite.ts", "src/runtime.ts"],
+ format: ["esm"],
+ dts: true,
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 701555961..16748bdca 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -525,7 +525,7 @@ importers:
version: 3.0.0
styled-jsx:
specifier: ^5.1.6
- version: 5.1.6(react@19.1.0)
+ version: 5.1.6(@babel/core@7.27.7)(react@19.1.0)
devDependencies:
'@hiogawa/unocss-preset-antd':
specifier: 2.2.1-pre.7
@@ -772,6 +772,34 @@ importers:
specifier: ^7.1.5
version: 7.1.5(@types/node@22.16.0)(jiti@2.5.1)(lightningcss@1.29.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0)
+ packages/remix-hmr:
+ dependencies:
+ magic-string:
+ specifier: ^0.30.17
+ version: 0.30.19
+ vite:
+ specifier: ^7.1.5
+ version: 7.1.5(@types/node@22.16.0)(jiti@2.5.1)(lightningcss@1.29.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0)
+ devDependencies:
+ '@remix-run/dom':
+ specifier: 0.0.0-experimental-remix-jam.6
+ version: 0.0.0-experimental-remix-jam.6
+
+ packages/remix-hmr/examples/basic:
+ devDependencies:
+ '@hiogawa/remix-hmr':
+ specifier: workspace:*
+ version: link:../..
+ '@remix-run/dom':
+ specifier: 0.0.0-experimental-remix-jam.6
+ version: 0.0.0-experimental-remix-jam.6
+ '@remix-run/events':
+ specifier: 0.0.0-experimental-remix-jam.5
+ version: 0.0.0-experimental-remix-jam.5
+ vite:
+ specifier: ^7.1.5
+ version: 7.1.5(@types/node@22.16.0)(jiti@2.5.1)(lightningcss@1.29.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0)
+
packages/rsc:
dependencies:
'@hiogawa/transforms':
@@ -12247,10 +12275,12 @@ snapshots:
dependencies:
inline-style-parser: 0.2.4
- styled-jsx@5.1.6(react@19.1.0):
+ styled-jsx@5.1.6(@babel/core@7.27.7)(react@19.1.0):
dependencies:
client-only: 0.0.1
react: 19.1.0
+ optionalDependencies:
+ '@babel/core': 7.27.7
sucrase@3.35.0:
dependencies: