diff --git a/.changeset/happy-rooms-burn.md b/.changeset/happy-rooms-burn.md
new file mode 100644
index 0000000..7ef9b30
--- /dev/null
+++ b/.changeset/happy-rooms-burn.md
@@ -0,0 +1,5 @@
+---
+"@marko/vite": patch
+---
+
+Fix marko virtual file HMR
diff --git a/src/__tests__/fixtures/isomorphic-basic-hmr/__snapshots__/dev-hmr.expected.md b/src/__tests__/fixtures/isomorphic-basic-hmr/__snapshots__/dev-hmr.expected.md
index 6bf55cc..25150ef 100644
--- a/src/__tests__/fixtures/isomorphic-basic-hmr/__snapshots__/dev-hmr.expected.md
+++ b/src/__tests__/fixtures/isomorphic-basic-hmr/__snapshots__/dev-hmr.expected.md
@@ -21,7 +21,7 @@ await page.click("#clickable")
```
-# HMR 0
+# HMR 0 (No Reload)
src/components/class-component.marko: "Clicks: ${state.clickCount}" → "Click count: ${state.clickCount}"
```diff
diff --git a/src/__tests__/fixtures/isomorphic-server-only-hmr/__snapshots__/build.expected.md b/src/__tests__/fixtures/isomorphic-server-only-hmr/__snapshots__/build.expected.md
new file mode 100644
index 0000000..cd52ed6
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-server-only-hmr/__snapshots__/build.expected.md
@@ -0,0 +1,29 @@
+# Loading
+
+```html
+
+ Hello
+
+
+
+ Server text
+
+
+ Mounted: true Clicks: 0
+
+
+```
+
+# Step 0
+await page.click("#clickable")
+
+```diff
+- Mounted: true Clicks: 0
++ Mounted: true Clicks: 1
+
+```
+
diff --git a/src/__tests__/fixtures/isomorphic-server-only-hmr/__snapshots__/dev-hmr.expected.md b/src/__tests__/fixtures/isomorphic-server-only-hmr/__snapshots__/dev-hmr.expected.md
new file mode 100644
index 0000000..920cb89
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-server-only-hmr/__snapshots__/dev-hmr.expected.md
@@ -0,0 +1,60 @@
+# Loading
+
+```html
+
+ Hello
+
+
+
+ Server text
+
+
+ Mounted: true Clicks: 0
+
+
+```
+
+# Step 0
+await page.click("#clickable")
+
+```diff
+- Mounted: true Clicks: 0
++ Mounted: true Clicks: 1
+
+```
+
+# HMR 0 (Reload)
+src/template.marko: "Hello
" → "Goodbye
"
+
+```diff
+- Hello
++ Goodbye
+- Mounted: true Clicks: 1
++ Mounted: true Clicks: 0
+
+```
+
+# HMR 0 Step 0
+await page.click("#clickable")
+
+```diff
+- Mounted: true Clicks: 0
++ Mounted: true Clicks: 1
+
+```
+
+# HMR 1 (Reload)
+src/components/implicit-component.marko: "Server text
" → "Updated
"
+
+```diff
+- Server text
++ Updated
+- Mounted: true Clicks: 1
++ Mounted: true Clicks: 0
+
+```
+
diff --git a/src/__tests__/fixtures/isomorphic-server-only-hmr/__snapshots__/dev.expected.md b/src/__tests__/fixtures/isomorphic-server-only-hmr/__snapshots__/dev.expected.md
new file mode 100644
index 0000000..cd52ed6
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-server-only-hmr/__snapshots__/dev.expected.md
@@ -0,0 +1,29 @@
+# Loading
+
+```html
+
+ Hello
+
+
+
+ Server text
+
+
+ Mounted: true Clicks: 0
+
+
+```
+
+# Step 0
+await page.click("#clickable")
+
+```diff
+- Mounted: true Clicks: 0
++ Mounted: true Clicks: 1
+
+```
+
diff --git a/src/__tests__/fixtures/isomorphic-server-only-hmr/dev-server.mjs b/src/__tests__/fixtures/isomorphic-server-only-hmr/dev-server.mjs
new file mode 100644
index 0000000..293d792
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-server-only-hmr/dev-server.mjs
@@ -0,0 +1,43 @@
+// In dev we'll start a Vite dev server in middleware mode,
+// and forward requests to our http request handler.
+
+import { createRequire } from "module";
+import path from "path";
+import url from "url";
+import { createServer } from "vite";
+
+// change to import once marko-vite is updated to ESM
+const markoPlugin = createRequire(import.meta.url)("../../..").default;
+
+const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
+
+const devServer = await createServer({
+ root: __dirname,
+ appType: "custom",
+ logLevel: "warn",
+ plugins: [markoPlugin()],
+ optimizeDeps: { force: true },
+ server: {
+ ws: false,
+ hmr: false,
+ middlewareMode: true,
+ watch: {
+ ignored: ["**/node_modules/**", "**/dist/**", "**/__snapshots__/**"],
+ },
+ },
+ build: {
+ assetsInlineLimit: 0,
+ },
+});
+
+export default devServer.middlewares.use(async (req, res, next) => {
+ try {
+ const { handler } = await devServer.ssrLoadModule(
+ path.join(__dirname, "./src/index.js"),
+ );
+ await handler(req, res, next);
+ } catch (err) {
+ devServer.ssrFixStacktrace(err);
+ return next(err);
+ }
+});
diff --git a/src/__tests__/fixtures/isomorphic-server-only-hmr/server.mjs b/src/__tests__/fixtures/isomorphic-server-only-hmr/server.mjs
new file mode 100644
index 0000000..76060b9
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-server-only-hmr/server.mjs
@@ -0,0 +1,16 @@
+// In production, simply start up the http server.
+import { createServer } from "http";
+import path from "path";
+import serve from "serve-handler";
+import url from "url";
+
+import { handler } from "./dist/index.mjs";
+
+const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
+const serveOpts = { public: path.resolve(__dirname, "dist") };
+
+export default createServer(async (req, res) => {
+ await handler(req, res);
+ if (res.headersSent) return;
+ await serve(req, res, serveOpts);
+});
diff --git a/src/__tests__/fixtures/isomorphic-server-only-hmr/src/components/class-component.marko b/src/__tests__/fixtures/isomorphic-server-only-hmr/src/components/class-component.marko
new file mode 100644
index 0000000..9579382
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-server-only-hmr/src/components/class-component.marko
@@ -0,0 +1,20 @@
+class {
+ onCreate() {
+ this.state = {
+ clickCount: 0,
+ mounted: false
+ };
+ }
+ onMount() {
+ this.state.mounted = true;
+ }
+
+ handleClick() {
+ this.state.clickCount++;
+ }
+}
+
+
+ Mounted: ${state.mounted}
+ Clicks: ${state.clickCount}
+
diff --git a/src/__tests__/fixtures/isomorphic-server-only-hmr/src/components/implicit-component.marko b/src/__tests__/fixtures/isomorphic-server-only-hmr/src/components/implicit-component.marko
new file mode 100644
index 0000000..df0a05d
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-server-only-hmr/src/components/implicit-component.marko
@@ -0,0 +1,10 @@
+static {
+ if (typeof window === "object") {
+ document.body.firstElementChild.append("Loaded Implicit Component");
+ }
+}
+
+
+
Server text
+
+
diff --git a/src/__tests__/fixtures/isomorphic-server-only-hmr/src/components/layout-component.marko b/src/__tests__/fixtures/isomorphic-server-only-hmr/src/components/layout-component.marko
new file mode 100644
index 0000000..9a1c58e
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-server-only-hmr/src/components/layout-component.marko
@@ -0,0 +1,15 @@
+static {
+ if (typeof window === "object") {
+ document.body.firstElementChild.append("Loaded Layout Component");
+ }
+}
+
+
+
+
+ Hello World
+
+
+ <${input.renderBody}/>
+
+
diff --git a/src/__tests__/fixtures/isomorphic-server-only-hmr/src/index.js b/src/__tests__/fixtures/isomorphic-server-only-hmr/src/index.js
new file mode 100644
index 0000000..d3f5422
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-server-only-hmr/src/index.js
@@ -0,0 +1,9 @@
+import template from "./template.marko";
+
+export function handler(req, res) {
+ if (req.url === "/") {
+ res.statusCode = 200;
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ template.render({}, res);
+ }
+}
diff --git a/src/__tests__/fixtures/isomorphic-server-only-hmr/src/template.marko b/src/__tests__/fixtures/isomorphic-server-only-hmr/src/template.marko
new file mode 100644
index 0000000..da11fa8
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-server-only-hmr/src/template.marko
@@ -0,0 +1,10 @@
+style {
+ div { color: green }
+}
+
+
+
+
Hello
+
+
+
diff --git a/src/__tests__/fixtures/isomorphic-server-only-hmr/test.config.ts b/src/__tests__/fixtures/isomorphic-server-only-hmr/test.config.ts
new file mode 100644
index 0000000..a757e8f
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-server-only-hmr/test.config.ts
@@ -0,0 +1,19 @@
+export const ssr = true;
+export async function steps() {
+ await page.click("#clickable");
+}
+export const hmr = [
+ {
+ changes: [["src/template.marko", "Hello
", "Goodbye
"]],
+ steps,
+ },
+ {
+ changes: [
+ [
+ "src/components/implicit-component.marko",
+ "Server text
",
+ "Updated
",
+ ],
+ ],
+ },
+];
diff --git a/src/__tests__/fixtures/isomorphic-tags-api-hmr/__snapshots__/dev-hmr.expected.md b/src/__tests__/fixtures/isomorphic-tags-api-hmr/__snapshots__/dev-hmr.expected.md
index ebb66cd..b3ec29c 100644
--- a/src/__tests__/fixtures/isomorphic-tags-api-hmr/__snapshots__/dev-hmr.expected.md
+++ b/src/__tests__/fixtures/isomorphic-tags-api-hmr/__snapshots__/dev-hmr.expected.md
@@ -6,7 +6,7 @@
```
-# HMR 0
+# HMR 0 (Reload)
src/tags/child-tag.marko: "Hello ${input.name}" → "Hi ${input.name}"
```diff
@@ -15,7 +15,7 @@ src/tags/child-tag.marko: "Hello ${input.name}" → "Hi ${input.name}"
```
-# HMR 1
+# HMR 1 (Reload)
src/tags/child-tag.marko: "Hi ${input.name}" → "Hey ${JSON.parse(JSON.stringify(input)).name}"
```diff
diff --git a/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/__snapshots__/build.expected.md b/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/__snapshots__/build.expected.md
new file mode 100644
index 0000000..dc42c20
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/__snapshots__/build.expected.md
@@ -0,0 +1,8 @@
+# Loading
+
+```html
+
+ Color: rgb(0, 128, 0)
+
+```
+
diff --git a/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/__snapshots__/dev-hmr.expected.md b/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/__snapshots__/dev-hmr.expected.md
new file mode 100644
index 0000000..1a2337c
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/__snapshots__/dev-hmr.expected.md
@@ -0,0 +1,17 @@
+# Loading
+
+```html
+
+ Color: rgb(0, 128, 0)
+
+```
+
+# HMR 0 (Reload)
+src/template.marko: "div { color: green }" → "div { color: blue }"
+
+```diff
+- Color: rgb(0, 128, 0)
++ Color: rgb(0, 0, 255)
+
+```
+
diff --git a/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/__snapshots__/dev.expected.md b/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/__snapshots__/dev.expected.md
new file mode 100644
index 0000000..dc42c20
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/__snapshots__/dev.expected.md
@@ -0,0 +1,8 @@
+# Loading
+
+```html
+
+ Color: rgb(0, 128, 0)
+
+```
+
diff --git a/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/dev-server.mjs b/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/dev-server.mjs
new file mode 100644
index 0000000..293d792
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/dev-server.mjs
@@ -0,0 +1,43 @@
+// In dev we'll start a Vite dev server in middleware mode,
+// and forward requests to our http request handler.
+
+import { createRequire } from "module";
+import path from "path";
+import url from "url";
+import { createServer } from "vite";
+
+// change to import once marko-vite is updated to ESM
+const markoPlugin = createRequire(import.meta.url)("../../..").default;
+
+const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
+
+const devServer = await createServer({
+ root: __dirname,
+ appType: "custom",
+ logLevel: "warn",
+ plugins: [markoPlugin()],
+ optimizeDeps: { force: true },
+ server: {
+ ws: false,
+ hmr: false,
+ middlewareMode: true,
+ watch: {
+ ignored: ["**/node_modules/**", "**/dist/**", "**/__snapshots__/**"],
+ },
+ },
+ build: {
+ assetsInlineLimit: 0,
+ },
+});
+
+export default devServer.middlewares.use(async (req, res, next) => {
+ try {
+ const { handler } = await devServer.ssrLoadModule(
+ path.join(__dirname, "./src/index.js"),
+ );
+ await handler(req, res, next);
+ } catch (err) {
+ devServer.ssrFixStacktrace(err);
+ return next(err);
+ }
+});
diff --git a/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/server.mjs b/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/server.mjs
new file mode 100644
index 0000000..76060b9
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/server.mjs
@@ -0,0 +1,16 @@
+// In production, simply start up the http server.
+import { createServer } from "http";
+import path from "path";
+import serve from "serve-handler";
+import url from "url";
+
+import { handler } from "./dist/index.mjs";
+
+const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
+const serveOpts = { public: path.resolve(__dirname, "dist") };
+
+export default createServer(async (req, res) => {
+ await handler(req, res);
+ if (res.headersSent) return;
+ await serve(req, res, serveOpts);
+});
diff --git a/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/src/index.js b/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/src/index.js
new file mode 100644
index 0000000..e36d061
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/src/index.js
@@ -0,0 +1,9 @@
+import template from "./template.marko";
+
+export function handler(req, res) {
+ if (req.url === "/") {
+ res.statusCode = 200;
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ template.render({}).pipe(res);
+ }
+}
diff --git a/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/src/tags/child-tag.marko b/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/src/tags/child-tag.marko
new file mode 100644
index 0000000..065c97b
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/src/tags/child-tag.marko
@@ -0,0 +1,5 @@
+
+
+Color: ${color}
diff --git a/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/src/tags/layout.marko b/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/src/tags/layout.marko
new file mode 100644
index 0000000..08de902
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/src/tags/layout.marko
@@ -0,0 +1,9 @@
+
+
+
+ Hello World
+
+
+ <${input.content}/>
+
+
diff --git a/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/src/template.marko b/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/src/template.marko
new file mode 100644
index 0000000..4888a50
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/src/template.marko
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/test.config.ts b/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/test.config.ts
new file mode 100644
index 0000000..997271b
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-tags-api-virtual-file-hmr/test.config.ts
@@ -0,0 +1,8 @@
+export const ssr = true;
+export const hmr = [
+ {
+ changes: [
+ ["src/template.marko", "div { color: green }", "div { color: blue }"],
+ ],
+ },
+];
diff --git a/src/__tests__/fixtures/isomorphic-virtual-file-hmr/__snapshots__/build.expected.md b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/__snapshots__/build.expected.md
new file mode 100644
index 0000000..f97ca9c
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/__snapshots__/build.expected.md
@@ -0,0 +1,14 @@
+# Loading
+
+```html
+
+
+ Color: rgb(0, 128, 0)
+
+
+```
+
diff --git a/src/__tests__/fixtures/isomorphic-virtual-file-hmr/__snapshots__/dev-hmr.expected.md b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/__snapshots__/dev-hmr.expected.md
new file mode 100644
index 0000000..f901fca
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/__snapshots__/dev-hmr.expected.md
@@ -0,0 +1,42 @@
+# Loading
+
+```html
+
+
+ Color: rgb(0, 128, 0)
+
+
+```
+
+# HMR 0 (No Reload)
+src/template.marko: "div { color: green }" → "div { color: blue }"
+
+(no change)
+
+# HMR 0 Step 0
+await page.click("#clickable")
+
+```diff
+- Color: rgb(0, 128, 0)
++ Color: rgb(0, 0, 255)
+
+```
+
+# HMR 1 (No Reload)
+src/template.marko: "div { color: blue }" → "div { color: red }"
+
+(no change)
+
+# HMR 1 Step 0
+await page.click("#clickable")
+
+```diff
+- Color: rgb(0, 0, 255)
++ Color: rgb(255, 0, 0)
+
+```
+
diff --git a/src/__tests__/fixtures/isomorphic-virtual-file-hmr/__snapshots__/dev.expected.md b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/__snapshots__/dev.expected.md
new file mode 100644
index 0000000..f97ca9c
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/__snapshots__/dev.expected.md
@@ -0,0 +1,14 @@
+# Loading
+
+```html
+
+
+ Color: rgb(0, 128, 0)
+
+
+```
+
diff --git a/src/__tests__/fixtures/isomorphic-virtual-file-hmr/dev-server.mjs b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/dev-server.mjs
new file mode 100644
index 0000000..293d792
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/dev-server.mjs
@@ -0,0 +1,43 @@
+// In dev we'll start a Vite dev server in middleware mode,
+// and forward requests to our http request handler.
+
+import { createRequire } from "module";
+import path from "path";
+import url from "url";
+import { createServer } from "vite";
+
+// change to import once marko-vite is updated to ESM
+const markoPlugin = createRequire(import.meta.url)("../../..").default;
+
+const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
+
+const devServer = await createServer({
+ root: __dirname,
+ appType: "custom",
+ logLevel: "warn",
+ plugins: [markoPlugin()],
+ optimizeDeps: { force: true },
+ server: {
+ ws: false,
+ hmr: false,
+ middlewareMode: true,
+ watch: {
+ ignored: ["**/node_modules/**", "**/dist/**", "**/__snapshots__/**"],
+ },
+ },
+ build: {
+ assetsInlineLimit: 0,
+ },
+});
+
+export default devServer.middlewares.use(async (req, res, next) => {
+ try {
+ const { handler } = await devServer.ssrLoadModule(
+ path.join(__dirname, "./src/index.js"),
+ );
+ await handler(req, res, next);
+ } catch (err) {
+ devServer.ssrFixStacktrace(err);
+ return next(err);
+ }
+});
diff --git a/src/__tests__/fixtures/isomorphic-virtual-file-hmr/server.mjs b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/server.mjs
new file mode 100644
index 0000000..76060b9
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/server.mjs
@@ -0,0 +1,16 @@
+// In production, simply start up the http server.
+import { createServer } from "http";
+import path from "path";
+import serve from "serve-handler";
+import url from "url";
+
+import { handler } from "./dist/index.mjs";
+
+const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
+const serveOpts = { public: path.resolve(__dirname, "dist") };
+
+export default createServer(async (req, res) => {
+ await handler(req, res);
+ if (res.headersSent) return;
+ await serve(req, res, serveOpts);
+});
diff --git a/src/__tests__/fixtures/isomorphic-virtual-file-hmr/src/components/class-component.marko b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/src/components/class-component.marko
new file mode 100644
index 0000000..2feaf63
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/src/components/class-component.marko
@@ -0,0 +1,18 @@
+class {
+ onCreate() {
+ this.state = {
+ color: undefined
+ };
+ }
+ onMount() {
+ this.updateColor();
+ }
+
+ updateColor() {
+ this.state.color = document.querySelector("#clickable").computedStyleMap().get("color");
+ }
+}
+
+
+ Color: ${state.color}
+
diff --git a/src/__tests__/fixtures/isomorphic-virtual-file-hmr/src/components/implicit-component.marko b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/src/components/implicit-component.marko
new file mode 100644
index 0000000..0225f24
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/src/components/implicit-component.marko
@@ -0,0 +1,9 @@
+static {
+ if (typeof window === "object") {
+ document.body.firstElementChild.append("Loaded Implicit Component");
+ }
+}
+
+
+
+
diff --git a/src/__tests__/fixtures/isomorphic-virtual-file-hmr/src/components/layout-component.marko b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/src/components/layout-component.marko
new file mode 100644
index 0000000..9a1c58e
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/src/components/layout-component.marko
@@ -0,0 +1,15 @@
+static {
+ if (typeof window === "object") {
+ document.body.firstElementChild.append("Loaded Layout Component");
+ }
+}
+
+
+
+
+ Hello World
+
+
+ <${input.renderBody}/>
+
+
diff --git a/src/__tests__/fixtures/isomorphic-virtual-file-hmr/src/index.js b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/src/index.js
new file mode 100644
index 0000000..d3f5422
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/src/index.js
@@ -0,0 +1,9 @@
+import template from "./template.marko";
+
+export function handler(req, res) {
+ if (req.url === "/") {
+ res.statusCode = 200;
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ template.render({}, res);
+ }
+}
diff --git a/src/__tests__/fixtures/isomorphic-virtual-file-hmr/src/template.marko b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/src/template.marko
new file mode 100644
index 0000000..7080531
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/src/template.marko
@@ -0,0 +1,9 @@
+style {
+ div { color: green }
+}
+
+
+
+
+
+
diff --git a/src/__tests__/fixtures/isomorphic-virtual-file-hmr/test.config.ts b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/test.config.ts
new file mode 100644
index 0000000..0be7322
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-virtual-file-hmr/test.config.ts
@@ -0,0 +1,20 @@
+export const ssr = true;
+export const hmr = [
+ {
+ changes: [
+ ["src/template.marko", "div { color: green }", "div { color: blue }"],
+ ],
+ async steps() {
+ await page.click("#clickable");
+ },
+ },
+
+ {
+ changes: [
+ ["src/template.marko", "div { color: blue }", "div { color: red }"],
+ ],
+ async steps() {
+ await page.click("#clickable");
+ },
+ },
+];
diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts
index f7e581a..9237ea6 100644
--- a/src/__tests__/main.test.ts
+++ b/src/__tests__/main.test.ts
@@ -12,6 +12,7 @@ import * as playwright from "playwright";
import url from "url";
import markoPlugin, { type Options } from "..";
+import injectHmrEventsPlugin from "./utils/inject-hmr-events-plugin";
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
@@ -238,13 +239,8 @@ for (const fixture of fs.readdirSync(FIXTURES)) {
root: dir,
server: {
port,
- watch: {
- ignored: [
- "**/node_modules/**",
- "**/dist/**",
- "**/__snapshots__/**",
- ],
- },
+ hmr: false,
+ watch: null,
},
logLevel: "error",
optimizeDeps: { force: true },
@@ -314,7 +310,7 @@ async function testHMR(dir: string, config: FixtureConfig) {
mode: "development",
appType: "custom",
logLevel: "error",
- plugins: [markoPlugin(config.options)],
+ plugins: [markoPlugin(config.options), injectHmrEventsPlugin()],
optimizeDeps: { force: true },
server: {
port,
@@ -343,6 +339,11 @@ async function testHMR(dir: string, config: FixtureConfig) {
});
}
+ let hmrReloads = 0;
+ function trackReloads() {
+ hmrReloads++;
+ }
+
try {
await devServer.listen();
@@ -387,6 +388,8 @@ async function testHMR(dir: string, config: FixtureConfig) {
prevRawHtml = await getRawHTML();
}
+ page.on("framenavigated", trackReloads);
+
// Run HMR steps.
for (const [hmrIdx, hmrStep] of hmrSteps.entries()) {
// Apply file changes.
@@ -405,21 +408,26 @@ async function testHMR(dir: string, config: FixtureConfig) {
fs.writeFileSync(filePath, newContent);
}
- // Wait for HMR update to propagate to the browser.
- // We watch for DOM changes on #app as a signal that HMR has taken effect.
- await page
- .waitForFunction(
- (prev) => {
- const app = document.getElementById("app");
- return app && app.innerHTML !== prev;
- },
- prevRawHtml,
- { timeout: 10000 },
- )
- .catch(() => {
- // Timeout is acceptable — the HMR update may not change the #app content,
- // or the page may have been fully reloaded.
- });
+ // // Wait for HMR update to propagate to the browser.
+ // // We listen for Vite HMR events and watch for DOM changes on #app as a signal that HMR has taken effect.
+
+ await Promise.any([
+ page.evaluate(() => (window as any).__nextHmr),
+ page
+ .waitForFunction(
+ (prev) => {
+ const app = document.getElementById("app");
+ return app && app.innerHTML !== prev;
+ },
+ prevRawHtml,
+ { timeout: 5000 },
+ )
+ .catch(() => {
+ /* Timeout */
+ }),
+ ]);
+
+ await page.waitForTimeout(100);
const hmrHtml = await getHTML();
const changesDesc = hmrStep.changes
@@ -428,7 +436,17 @@ async function testHMR(dir: string, config: FixtureConfig) {
`${file}: ${JSON.stringify(find)} → ${JSON.stringify(replace)}`,
)
.join("\n");
- snapshot += `# HMR ${hmrIdx}\n${changesDesc}\n\n`;
+
+ snapshot += `# HMR ${hmrIdx}`;
+
+ if (hmrReloads) {
+ snapshot += ` (Reload${hmrReloads > 1 ? ` x${hmrReloads}` : ""})`;
+ hmrReloads = 0;
+ } else {
+ snapshot += ` (No Reload)`;
+ }
+
+ snapshot += `\n${changesDesc}\n\n`;
if (hmrHtml !== prevHtml) {
snapshot += htmlSnapshot(hmrHtml, prevHtml);
@@ -462,6 +480,8 @@ async function testHMR(dir: string, config: FixtureConfig) {
for (const [filePath, content] of originalFiles) {
fs.writeFileSync(filePath, content);
}
+ page.off("framenavigated", trackReloads);
+
await devServer.close();
}
}
diff --git a/src/__tests__/utils/inject-hmr-events-plugin.ts b/src/__tests__/utils/inject-hmr-events-plugin.ts
new file mode 100644
index 0000000..2aa727f
--- /dev/null
+++ b/src/__tests__/utils/inject-hmr-events-plugin.ts
@@ -0,0 +1,44 @@
+import type { Plugin } from "vite";
+
+export default (): Plugin => {
+ return {
+ name: "inject-hmr-events",
+ enforce: "pre",
+ apply: "serve",
+ transform(source, id) {
+ if (id.endsWith("client-entry.marko")) {
+ return (
+ source +
+ `
+if (import.meta.hot) {
+ import.meta.hot.on("vite:afterUpdate", () => {
+ window.__nextHmr.resolve();
+ window.__nextHmr = createDeferred();
+ });
+ import.meta.hot.on("vite:error", (err) => {
+ window.__nextHmr.reject(err);
+ window.__nextHmr = createDeferred();
+ });
+
+ window.__nextHmr ||= createDeferred();
+
+ function createDeferred() {
+ let resolve;
+ let reject;
+ const deferred = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ })
+ deferred.resolve = resolve;
+ deferred.reject = reject;
+ return deferred;
+ }
+} else {
+ window.__nextHmr ||= Promise.reject("import.meta.hot not defined");
+}
+`
+ );
+ }
+ },
+ };
+};
diff --git a/src/index.ts b/src/index.ts
index b3f939c..057b023 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -86,6 +86,8 @@ const virtualFiles = new Map<
string,
VirtualFile | DeferredPromise
>();
+const virtualFilesForTemplate = new Map>();
+const ssrTransformCache = new Map();
const importTagReg = /^<([^>]+)>$/;
const optionalWatchFileReg =
/[\\/](?:([^\\/]+)\.)?(?:marko-tag.json|(?:style|component|component-browser)\.\w+)$/;
@@ -149,6 +151,12 @@ export default function markoPlugin(opts: Options = {}): vite.Plugin[] {
if (isDeferredPromise(prev)) {
prev.resolve(virtualFile);
}
+
+ let files = virtualFilesForTemplate.get(normalizedFrom);
+ if (!files) {
+ virtualFilesForTemplate.set(normalizedFrom, (files = new Set()));
+ }
+ files.add(id);
}
virtualFiles.set(id, virtualFile);
@@ -464,6 +472,7 @@ export default function markoPlugin(opts: Options = {}): vite.Plugin[] {
entryIds.delete(fileName);
transformWatchFiles.delete(fileName);
transformAnalyzedTags.delete(fileName);
+ virtualFilesForTemplate.delete(fileName);
}
for (const [id, files] of transformWatchFiles) {
@@ -487,36 +496,79 @@ export default function markoPlugin(opts: Options = {}): vite.Plugin[] {
});
},
- hotUpdate(ctx) {
+ async hotUpdate(ctx) {
compiler.taglib.clearCaches();
baseConfig.cache!.clear();
clearScanCache();
- for (const mod of ctx.modules) {
- if (mod.id && virtualFiles.has(mod.id)) {
- virtualFiles.set(mod.id, createDeferredPromise());
- }
- }
+ const modules = new Set(ctx.modules);
+ const fileName = normalizePath(ctx.file);
// When a child tag template changes, parent templates that analyzed
// it may need recompilation (e.g., input destructuring changes in
// Tags API affect the parent's compiled output).
- const fileName = normalizePath(ctx.file);
- let extraModules: undefined | typeof ctx.modules;
for (const [parent, files] of transformAnalyzedTags) {
- if (parent !== fileName && files.has(fileName)) {
+ if (files.has(fileName)) {
const mods = this.environment.moduleGraph.getModulesByFile(parent);
if (mods) {
- extraModules ||= [];
for (const mod of mods) {
- extraModules.push(mod);
+ modules.add(mod);
+ }
+ }
+ }
+ }
+
+ // When a .marko file changes, its virtual dependencies (e.g.,
+ // extracted CSS from style {} blocks) also need to be included
+ // in the HMR update so the browser receives the new content.
+ const virtualFileIds = virtualFilesForTemplate.get(fileName);
+ if (virtualFileIds) {
+ for (const id of virtualFileIds) {
+ if (!isDeferredPromise(virtualFiles.get(id))) {
+ virtualFiles.set(id, createDeferredPromise());
+ }
+ const mods = this.environment.moduleGraph.getModulesByFile(id);
+ if (mods) {
+ for (const mod of mods) {
+ this.environment.moduleGraph.invalidateModule(mod);
+ modules.add(mod);
+ }
+ }
+ }
+ }
+
+ if (linked && this.environment.name === "ssr") {
+ const previous = new Map();
+
+ for (const mod of modules) {
+ const code = ssrTransformCache.get(mod.id!);
+ if (code !== undefined) {
+ previous.set(mod, code);
+ }
+ }
+
+ if (previous.size) {
+ let reload = false;
+ for (const [mod, prevCode] of previous) {
+ await this.environment.transformRequest(mod.id!);
+ if (
+ prevCode !== ssrTransformCache.get(mod.id!) &&
+ !devServer.environments.client.moduleGraph.getModulesByFile(
+ mod.id!,
+ )
+ ) {
+ reload = true;
}
}
+
+ if (reload) {
+ devServer.hot.send({ type: "full-reload" });
+ }
}
}
- if (extraModules) {
- return [...ctx.modules, ...extraModules];
+ if (modules.size !== ctx.modules.length) {
+ return [...modules];
}
},
@@ -726,18 +778,15 @@ export default function markoPlugin(opts: Options = {}): vite.Plugin[] {
return virtualFiles.get(id) || cachedSources.get(id) || null;
},
async transform(source, rawId) {
- const resolvedId = stripViteQueries(rawId);
- const info = getMarkoFileInfo(resolvedId);
- let id =
- info?.kind === InternalFileKind.virtual
- ? resolvedId
- : info?.sourceId || resolvedId;
+ const id = stripViteQueries(rawId);
+ const info = getMarkoFileInfo(id);
const isSSR = this.environment.name === "ssr";
+ const isClientEntry = info?.kind === InternalFileKind.clientEntry;
+ const isServerEntry = info?.kind === InternalFileKind.serverEntry;
- if (info?.kind === InternalFileKind.serverEntry) {
- const fileName = id;
+ if (isServerEntry) {
+ const fileName = info.sourceId;
let mainEntryData: string;
- id = resolvedId;
cachedSources.set(fileName, source);
if (isBuild) {
@@ -816,6 +865,12 @@ export default function markoPlugin(opts: Options = {}): vite.Plugin[] {
return null;
}
+ const fileName = isClientEntry ? info.sourceId : id;
+
+ if (devServer) {
+ virtualFilesForTemplate.delete(normalizePath(fileName));
+ }
+
if (isSSR) {
if (linked) {
cachedSources.set(id, source);
@@ -840,14 +895,14 @@ export default function markoPlugin(opts: Options = {}): vite.Plugin[] {
const compiled = await compiler.compile(
source,
- id,
+ fileName,
isSSR
? isCJSModule(id, rootResolveFile)
? serverCJSConfig
- : info?.kind === InternalFileKind.serverEntry
+ : isServerEntry
? serverEntryConfig
: serverConfig
- : info?.kind === InternalFileKind.clientEntry
+ : isClientEntry
? clientEntryConfig
: clientConfig,
);
@@ -855,21 +910,29 @@ export default function markoPlugin(opts: Options = {}): vite.Plugin[] {
const { meta } = compiled;
let { code } = compiled;
- if (serverManifest && info?.kind === InternalFileKind.clientEntry) {
+ if (serverManifest && isClientEntry) {
for (const assetId of serverManifest.ssrAssetIds) {
code += `\nimport "${relativeImportPath(id, path.resolve(root, assetId))}";`;
}
}
- if (!info && !isTest && devServer && !isTagsApi(meta.api)) {
+ if (!isTest && devServer && !isTagsApi(meta.api)) {
code += `\nif (import.meta.hot) import.meta.hot.accept(() => {});`;
}
if (devServer) {
- transformWatchFiles.set(id, meta.watchFiles);
+ if (isSSR && !isServerEntry) {
+ ssrTransformCache.set(id, code);
+ }
+ if (!info) {
+ transformWatchFiles.set(id, meta.watchFiles);
+ }
if (meta.analyzedTags) {
const files = new Set();
transformAnalyzedTags.set(id, files);
+ if (isClientEntry) {
+ files.add(normalizePath(info.sourceId));
+ }
for (const file of meta.analyzedTags) {
files.add(normalizePath(file));
}