Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/remix-hmr/README.md
Original file line number Diff line number Diff line change
@@ -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()],
});
```
15 changes: 15 additions & 0 deletions packages/remix-hmr/examples/basic/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>tiny-refresh demo</title>
<meta
name="viewport"
content="width=device-width, height=device-height, initial-scale=1.0"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/index.tsx"></script>
</body>
</html>
14 changes: 14 additions & 0 deletions packages/remix-hmr/examples/basic/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Binary file not shown.
9 changes: 9 additions & 0 deletions packages/remix-hmr/examples/basic/src/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createRoot } from "@remix-run/dom";
import { Root } from "./root";

function main() {
const el = document.getElementById("root")!;
createRoot(el).render(<Root />);
}

main();
41 changes: 41 additions & 0 deletions packages/remix-hmr/examples/basic/src/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Remix } from "@remix-run/dom";
import { dom } from "@remix-run/events";

export function Root() {
return (
<div>
<input style={{ marginBottom: "0.5rem" }} placeholder="test-input" />
<Counter />
</div>
);
}

function Counter(this: Remix.Handle) {
let count = 0;
const setCount = (v: number) => {
count = v;
this.update();
};

return () => {
return (
<div>
<div style={{ marginRight: "0.5rem" }}>Count: {count}</div>
<button
on={dom.click(() => {
setCount(count - 1);
})}
>
-1
</button>
<button
on={dom.click(() => {
setCount(count + 1);
})}
>
+1
</button>
</div>
);
};
}
9 changes: 9 additions & 0 deletions packages/remix-hmr/examples/basic/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../../../tsconfig.base.json",
"include": ["src", "*.ts"],
"compilerOptions": {
"types": ["vite/client"],
"jsx": "react-jsx",
"jsxImportSource": "@remix-run/dom"
}
}
9 changes: 9 additions & 0 deletions packages/remix-hmr/examples/basic/vite.config.ts
Original file line number Diff line number Diff line change
@@ -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()],
});
32 changes: 32 additions & 0 deletions packages/remix-hmr/package.json
Original file line number Diff line number Diff line change
@@ -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": "*"
}
}
1 change: 1 addition & 0 deletions packages/remix-hmr/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {};
116 changes: 116 additions & 0 deletions packages/remix-hmr/src/runtime.ts
Original file line number Diff line number Diff line change
@@ -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<string, ProxyEntry>();
public componentMap = new Map<string, ComponentEntry>();
public setup = () => {};

constructor(public options: RefreshRuntimeOptions) {}

wrap(name: string, Component: FC, key: string): FC {

Check failure on line 35 in packages/remix-hmr/src/runtime.ts

View workflow job for this annotation

GitHub Actions / test

Cannot find name 'FC'.

Check failure on line 35 in packages/remix-hmr/src/runtime.ts

View workflow job for this annotation

GitHub Actions / test

Cannot find name '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;
}
78 changes: 78 additions & 0 deletions packages/remix-hmr/src/transform.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
"
`);
});
});
Loading
Loading