diff --git a/packages/tiny-react/src/hmr/index.test.tsx b/packages/tiny-react/src/hmr/index.test.tsx
index eec83424..a9dc4b7f 100644
--- a/packages/tiny-react/src/hmr/index.test.tsx
+++ b/packages/tiny-react/src/hmr/index.test.tsx
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
-import { type ViteHot, createManager } from ".";
+import { type ViteHot, initialize } from ".";
import { useEffect, useReducer } from "../hooks";
import { render } from "../reconciler";
import { sleepFrame } from "../test-utils";
@@ -45,7 +45,7 @@ describe("hmr", () => {
return
1
;
}
- const manager = createManager(hot, runtime, false);
+ const manager = initialize(hot, runtime, { mode: "vite", debug: false });
ChildExport = manager.wrap("Child", Child, "useEffect");
manager.setup();
}
@@ -78,7 +78,7 @@ describe("hmr", () => {
return 2
;
}
- const manager = createManager(hot, runtime, false);
+ const manager = initialize(hot, runtime, { mode: "vite", debug: false });
manager.wrap("Child", Child, "");
manager.setup();
}
@@ -119,7 +119,7 @@ describe("hmr", () => {
return 3
;
}
- const manager = createManager(hot, runtime, false);
+ const manager = initialize(hot, runtime, { mode: "vite", debug: false });
manager.wrap("Child", Child, "useEffect");
manager.setup();
}
diff --git a/packages/tiny-refresh/src/runtime.test.tsx b/packages/tiny-refresh/src/runtime.test.tsx
index 5e374f25..7575302c 100644
--- a/packages/tiny-refresh/src/runtime.test.tsx
+++ b/packages/tiny-refresh/src/runtime.test.tsx
@@ -3,7 +3,7 @@
import { act, cleanup, render } from "@testing-library/react";
import React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
-import { type ViteHot, createManager } from "./runtime";
+import { type ViteHot, initialize } from "./runtime";
afterEach(cleanup);
@@ -37,7 +37,7 @@ describe("hmr", () => {
return 1
;
}
- const manager = createManager(hot, React, false);
+ const manager = initialize(hot, React, { mode: "vite", debug: false });
ChildExport = manager.wrap("Child", Child, "useEffect");
manager.setup();
}
@@ -70,7 +70,7 @@ describe("hmr", () => {
function Child() {
return 2
;
}
- const manager = createManager(hot, React, false);
+ const manager = initialize(hot, React, { mode: "vite", debug: false });
manager.wrap("Child", Child, "");
manager.setup();
}
@@ -111,7 +111,7 @@ describe("hmr", () => {
return 3
;
}
- const manager = createManager(hot, React, false);
+ const manager = initialize(hot, React, { mode: "vite", debug: false });
manager.wrap("Child", Child, "useEffect");
manager.setup();
}
@@ -154,7 +154,7 @@ describe("hmr", () => {
return 4
;
}
- const manager = createManager(hot, React, false);
+ const manager = initialize(hot, React, { mode: "vite", debug: false });
manager.wrap("Child", Child, "useEffect");
manager.setup();
}
@@ -191,7 +191,7 @@ describe("hmr", () => {
return 5
;
}
- const manager = createManager(hot, React, false);
+ const manager = initialize(hot, React, { mode: "vite", debug: false });
manager.wrap("Child", Child, "");
manager.setup();
}
diff --git a/packages/tiny-refresh/src/runtime.ts b/packages/tiny-refresh/src/runtime.ts
index bbf5adfa..f5a53164 100644
--- a/packages/tiny-refresh/src/runtime.ts
+++ b/packages/tiny-refresh/src/runtime.ts
@@ -1,13 +1,24 @@
+import type { RefreshRuntimeOptions } from "./transform";
+
const MANAGER_KEY = Symbol.for("tiny-refresh.manager");
export interface ViteHot {
accept: (onNewModule: (newModule?: unknown) => void) => void;
invalidate: (message?: string) => void;
- data: {
- [MANAGER_KEY]?: Manager;
- };
+ data: HotData;
+}
+
+interface WebpackHot {
+ accept: (cb?: () => void) => void;
+ invalidate: () => void;
+ dispose: (cb: (data: HotData) => void) => void;
+ data?: HotData;
}
+type HotData = {
+ [MANAGER_KEY]?: Manager;
+};
+
type FC = (props: any) => unknown;
interface Runtime {
@@ -27,16 +38,14 @@ interface ComponentEntry {
}
// singleton per file
-export class Manager {
+class Manager {
public proxyMap = new Map();
public componentMap = new Map();
+ public setup = () => {};
constructor(
- public options: {
- hot: ViteHot;
- runtime: Runtime;
- debug?: boolean;
- }
+ public runtime: Runtime,
+ public options: RefreshRuntimeOptions
) {}
wrap(name: string, Component: FC, key: string): FC {
@@ -49,16 +58,6 @@ export class Manager {
return proxy.Component;
}
- setup() {
- // https://vitejs.dev/guide/api-hmr.html#hot-accept-cb
- this.options.hot.accept((newModule) => {
- const ok = newModule && this.patch();
- if (!ok) {
- this.options.hot.invalidate();
- }
- });
- }
-
patch() {
// TODO: debounce re-rendering?
const componentNames = new Set([
@@ -84,16 +83,8 @@ export class Manager {
}
}
-export function createManager(
- hot: ViteHot,
- runtime: Runtime,
- debug?: boolean
-): Manager {
- return (hot.data[MANAGER_KEY] ??= new Manager({ hot, runtime, debug }));
-}
-
function createProxyComponent(manager: Manager, name: string): ProxyEntry {
- const { createElement, useEffect, useReducer } = manager.options.runtime;
+ const { createElement, useEffect, useReducer } = manager.runtime;
const listeners = new Set<() => void>();
@@ -128,3 +119,63 @@ function createProxyComponent(manager: Manager, name: string): ProxyEntry {
return { Component, listeners };
}
+
+//
+// HMR API integration
+//
+
+export function initialize(
+ hot: ViteHot | WebpackHot,
+ runtime: Runtime,
+ options: RefreshRuntimeOptions
+) {
+ if (options.mode === "vite") {
+ return initializeVite(hot as any, runtime, options);
+ }
+ if (options.mode === "webpack") {
+ return initializeWebpack(hot as any, runtime, options);
+ }
+ return options.mode satisfies never;
+}
+
+function initializeVite(
+ hot: ViteHot,
+ runtime: Runtime,
+ options: RefreshRuntimeOptions
+) {
+ const manager = (hot.data[MANAGER_KEY] ??= new Manager(runtime, 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;
+}
+
+function initializeWebpack(
+ hot: WebpackHot,
+ runtime: Runtime,
+ options: RefreshRuntimeOptions
+) {
+ // 'hot.data' is passed from old module via hot.dispose(data)
+ // https://webpack.js.org/api/hot-module-replacement/#dispose-or-adddisposehandler
+ const prevData = hot.data?.[MANAGER_KEY];
+ const manager = prevData ?? new Manager(runtime, options);
+
+ hot.accept();
+ hot.dispose((data) => {
+ data[MANAGER_KEY] = manager;
+ });
+
+ manager.setup = () => {
+ if (prevData && !manager.patch()) {
+ hot.invalidate();
+ }
+ };
+
+ return manager;
+}
diff --git a/packages/tiny-refresh/src/transform.test.ts b/packages/tiny-refresh/src/transform.test.ts
index 50075102..07722b8a 100644
--- a/packages/tiny-refresh/src/transform.test.ts
+++ b/packages/tiny-refresh/src/transform.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
-import { hmrTransform } from "./transform";
+import { transform } from "./transform";
-describe(hmrTransform, () => {
+describe(transform, () => {
it("basic", async () => {
const input = /* js */ `\
@@ -28,9 +28,11 @@ const NotFn = "hello";
// export const NotFn2 = "hello";
`;
expect(
- await hmrTransform(input, {
+ await transform(input, {
runtime: "/runtime",
refreshRuntime: "/refresh-runtime",
+ mode: "vite",
+ debug: false,
})
).toMatchInlineSnapshot(`
"
@@ -59,20 +61,18 @@ const NotFn = "hello";
import * as $$runtime from "/runtime";
import * as $$refresh from "/refresh-runtime";
if (import.meta.hot) {
- () => import.meta.hot.accept();
- const $$manager = $$refresh.createManager(
+ (() => import.meta.hot.accept());
+ const $$manager = $$refresh.initialize(
import.meta.hot,
- {
- createElement: $$runtime.createElement,
- useReducer: $$runtime.useReducer,
- useEffect: $$runtime.useEffect,
- },
- false,
+ $$runtime,
+ {"runtime":"/runtime","refreshRuntime":"/refresh-runtime","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/tiny-refresh/src/transform.ts b/packages/tiny-refresh/src/transform.ts
index 368402f2..1d5d9806 100644
--- a/packages/tiny-refresh/src/transform.ts
+++ b/packages/tiny-refresh/src/transform.ts
@@ -1,38 +1,42 @@
import type * as estree from "estree";
import { parseAstAsync } from "vite";
-interface HmrTransformOptions {
- runtime: string; // e.g. "react", "preact/compat", "@hiogawa/tiny-react"
- refreshRuntime: string; // allow "@hiogawa/tiny-react" to re-export refresh runtime by itself to simplify dependency
- debug?: boolean;
+export interface TransformOptions {
+ // "react", "preact/compat", "@hiogawa/tiny-react"
+ runtime: string;
+ // allow "@hiogawa/tiny-react" to re-export refresh runtime by itself to simplify dependency
+ refreshRuntime: string;
+ mode: "vite" | "webpack";
+ debug: boolean;
}
-export async function hmrTransform(code: string, options: HmrTransformOptions) {
+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;
}
- let footer = /* js */ `
+ const hot =
+ options.mode === "vite" ? "import.meta.hot" : "import.meta.webpackHot";
+ 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 $$runtime from "${options.runtime}";
import * as $$refresh from "${options.refreshRuntime}";
-if (import.meta.hot) {
- () => import.meta.hot.accept();
- const $$manager = $$refresh.createManager(
- import.meta.hot,
- {
- createElement: $$runtime.createElement,
- useReducer: $$runtime.useReducer,
- useEffect: $$runtime.useEffect,
- },
- ${options.debug ?? false},
+if (${hot}) {
+ (() => ${hot}.accept());
+ const $$manager = $$refresh.initialize(
+ ${hot},
+ $$runtime,
+ ${JSON.stringify(options)}
);
-`;
- for (const { id, hooks } of result.entries) {
- footer += `\
- ${id} = $$manager.wrap("${id}", ${id}, ${JSON.stringify(hooks.join("/"))});
-`;
- }
- footer += `\
+
+${wrap}
$$manager.setup();
}
`;
@@ -40,52 +44,6 @@ if (import.meta.hot) {
return result.outCode + footer;
}
-export async function transformWebpack(
- code: string,
- options: HmrTransformOptions
-) {
- const result = await analyzeCode(code);
- if (result.errors.length || result.entries.length === 0) {
- return;
- }
- let footer = /* js */ `
-import * as $$runtime from "${options.runtime}";
-import * as $$refresh from "${options.refreshRuntime}";
-if (import.meta.webpackHot) {
- // 'hot.data' is passed from old module via hot.dispose(data)
- // https://webpack.js.org/api/hot-module-replacement/#dispose-or-adddisposehandler
- const hot = import.meta.webpackHot;
- const MANAGER_KEY = Symbol.for("tiny-refresh.manager");
- const $$manager = hot.data?.[MANAGER_KEY] ?? new $$refresh.Manager({
- hot: {},
- runtime: {
- createElement: $$runtime.createElement,
- useReducer: $$runtime.useReducer,
- useEffect: $$runtime.useEffect,
- },
- debug: ${options.debug ?? false},
- });
- hot.dispose(data => {
- data[MANAGER_KEY] = $$manager;
- });
- hot.accept();
-`;
- for (const { id, hooks } of result.entries) {
- footer += `\
- ${id} = $$manager.wrap("${id}", ${id}, ${JSON.stringify(hooks.join("/"))});
-`;
- }
- footer += `\
- if (hot.data?.[MANAGER_KEY]) {
- if (!$$manager.patch()) {
- hot.invalidate();
- }
- }
-}
-`;
- return result.outCode + footer;
-}
-
//
// extract component declarations
//
@@ -109,8 +67,6 @@ const COMPONENT_RE = /^[A-Z]/;
async function analyzeCode(code: string) {
const ast = await parseAstAsync(code);
const errors: unknown[] = [];
-
- // TODO: collect also non-exported functions with a capitalized names
const entries: ParsedEntry[] = [];
// replace "export const" with "export let"
diff --git a/packages/tiny-refresh/src/vite.ts b/packages/tiny-refresh/src/vite.ts
index 566c253d..b57e1682 100644
--- a/packages/tiny-refresh/src/vite.ts
+++ b/packages/tiny-refresh/src/vite.ts
@@ -1,5 +1,5 @@
import { type FilterPattern, type Plugin, createFilter } from "vite";
-import { hmrTransform } from "./transform";
+import { transform } from "./transform";
export function vitePluginTinyRefresh(options?: {
include?: FilterPattern;
@@ -17,9 +17,10 @@ export function vitePluginTinyRefresh(options?: {
apply: "serve",
transform(code, id, transformOptions) {
if (!transformOptions?.ssr && filter(id)) {
- return hmrTransform(code, {
+ return transform(code, {
runtime: options?.runtime ?? "react",
refreshRuntime: options?.refreshRuntime ?? "@hiogawa/tiny-refresh",
+ mode: "vite",
debug: true,
});
}
diff --git a/packages/tiny-refresh/src/webpack.ts b/packages/tiny-refresh/src/webpack.ts
index 3cc06dd0..a5e3a71d 100644
--- a/packages/tiny-refresh/src/webpack.ts
+++ b/packages/tiny-refresh/src/webpack.ts
@@ -1,4 +1,4 @@
-import { transformWebpack } from "./transform";
+import { transform } from "./transform";
export type TinyRefreshLoaderOptions = {
refreshRuntime?: string;
@@ -9,9 +9,10 @@ export type TinyRefreshLoaderOptions = {
export default function loader(this: any, input: string) {
const callback = this.async();
const options = this.getOptions() as TinyRefreshLoaderOptions;
- transformWebpack(input, {
+ transform(input, {
refreshRuntime: options.refreshRuntime ?? "@hiogawa/tiny-refresh",
runtime: "react",
+ mode: "webpack",
debug: true,
}).then(
(result) => callback(null, result ?? input),