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: