diff --git a/server/src/__tests__/vite-html-renderer.test.ts b/server/src/__tests__/vite-html-renderer.test.ts index add3ba8004..46b065f913 100644 --- a/server/src/__tests__/vite-html-renderer.test.ts +++ b/server/src/__tests__/vite-html-renderer.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createCachedViteHtmlRenderer, type ViteWatcherHost } from "../vite-html-renderer.js"; function createWatcher() { @@ -32,7 +32,7 @@ describe("createCachedViteHtmlRenderer", () => { } }); - it("reuses the injected dev html shell until index.html changes", async () => { + it("caches the branded template until index.html changes while transforming every request", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-")); tempDirs.push(tempDir); const indexPath = path.join(tempDir, "index.html"); @@ -43,25 +43,36 @@ describe("createCachedViteHtmlRenderer", () => { ); const watcher = createWatcher(); + const transformIndexHtml = vi.fn(async (_url: string, html: string) => + html.replace( + '', + '\n', + ), + ); + const brandHtml = vi.fn((html: string) => html.replace("
", '')); const vite: ViteWatcherHost = { watcher, + transformIndexHtml, }; - const renderer = createCachedViteHtmlRenderer({ vite, uiRoot: tempDir }); + const renderer = createCachedViteHtmlRenderer({ vite, uiRoot: tempDir, brandHtml }); await expect(renderer.render("/")).resolves.toContain("/@vite/client"); - await expect(renderer.render("/")).resolves.toContain('"/@react-refresh"'); const first = await renderer.render("/"); const second = await renderer.render("/issues"); expect(first).toBe(second); + expect(first).toContain('data-brand="paperclip"'); expect(first.match(/\/@vite\/client/g)?.length).toBe(1); - expect(first).toContain("window.$RefreshReg$"); + expect(brandHtml).toHaveBeenCalledTimes(1); + expect(transformIndexHtml).toHaveBeenCalledTimes(3); + expect(transformIndexHtml).toHaveBeenLastCalledWith("/issues", expect.stringContaining("v1")); const sourcePath = path.join(tempDir, "src", "main.tsx"); fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); fs.writeFileSync(sourcePath, "export {};\n", "utf8"); watcher.emit("change", sourcePath); expect(await renderer.render("/")).toBe(first); + expect(brandHtml).toHaveBeenCalledTimes(1); fs.writeFileSync( indexPath, @@ -71,27 +82,33 @@ describe("createCachedViteHtmlRenderer", () => { watcher.emit("change", indexPath); await expect(renderer.render("/")).resolves.toContain("v2"); + expect(brandHtml).toHaveBeenCalledTimes(2); renderer.dispose(); }); - it("does not duplicate the vite client tag or react refresh preamble when already present", async () => { + it("runs Vite's HTML transform on every render so HMR entry timestamps stay current", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-")); tempDirs.push(tempDir); fs.writeFileSync( path.join(tempDir, "index.html"), - '', + '', "utf8", ); + let timestamp = 0; + const transformIndexHtml = vi.fn(async (_url: string, html: string) => + html.replace("/src/main.tsx", `/src/main.tsx?t=${++timestamp}`), + ); const vite: ViteWatcherHost = { watcher: createWatcher(), + transformIndexHtml, }; const renderer = createCachedViteHtmlRenderer({ vite, uiRoot: tempDir }); - const html = await renderer.render("/"); - expect(html.match(/\/@vite\/client/g)?.length).toBe(1); - expect(html.match(/\/@react-refresh/g)?.length).toBe(1); + await expect(renderer.render("/")).resolves.toContain("/src/main.tsx?t=1"); + await expect(renderer.render("/issues/ISS-1")).resolves.toContain("/src/main.tsx?t=2"); + expect(transformIndexHtml).toHaveBeenCalledTimes(2); }); }); diff --git a/server/src/app.ts b/server/src/app.ts index 3c0b284341..669823a648 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -774,9 +774,18 @@ export async function createApp( res.end("Upgrade Required"); }); const { createServer: createViteServer } = await import("vite"); + const configuredViteCacheDir = process.env.PAPERCLIP_VITE_CACHE_DIR?.trim(); const vite = await createViteServer({ root: uiRoot, + ...(configuredViteCacheDir + ? { cacheDir: path.resolve(configuredViteCacheDir) } + : {}), appType: "custom", + // Vite otherwise discovers every HTML entry below the UI root. Generated + // Storybook output can reference dependencies that are intentionally not + // part of the application install, poisoning a clean embedded dev-server + // cache before the browser opens. The embedded UI has one real entry. + optimizeDeps: { entries: [path.resolve(uiRoot, "index.html")] }, server: { // Listener binding and browser HMR hostname are deliberately separate: // exposed branch runtimes stay loopback-only while the browser uses the diff --git a/server/src/vite-html-renderer.ts b/server/src/vite-html-renderer.ts index 983a3ea158..171686aed3 100644 --- a/server/src/vite-html-renderer.ts +++ b/server/src/vite-html-renderer.ts @@ -4,6 +4,7 @@ import path from "node:path"; type ViteWatcherEvent = "add" | "change" | "unlink"; export interface ViteWatcherHost { + transformIndexHtml(url: string, html: string): Promise