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)); }