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),