diff --git a/clients/web/server/vite-base-config.ts b/clients/web/server/vite-base-config.ts index 3d9c1a90..9a0a4609 100644 --- a/clients/web/server/vite-base-config.ts +++ b/clients/web/server/vite-base-config.ts @@ -55,11 +55,47 @@ export function getViteBaseConfig() { // components import it at runtime (e.g. `protocolUtils`'s // `isInputRequiredResult` / `SUBSCRIPTION_ID_META_KEY`), so listing it // here keeps the dep graph stable across story files. + // + // That was one instance of a class, not the class: any package a story + // or component starts importing after the cache was written is + // discovered the same way, and pinning each one here would chase the + // symptom. `getStorybookOptimizeDeps` below adds `force` so every run + // re-scans the story entries up front (#2340); this entry stays as the + // explicit include it always was. include: ["@modelcontextprotocol/client"], }, }; } +/** + * Storybook (browser Vitest project) optimizeDeps: the base set, re-bundled + * on every run instead of read from the cache (#2340). + * + * Vite keys that cache on the lockfile and on the config, never on what the + * story graph imports, so a cached bundle stays "valid" after a story or + * component gains an import of a package that is already installed. With a + * valid cache Vite skips the scan, and the first request for the new package + * re-runs the optimizer mid-run: the shared chunks are rewritten, the `?v=` + * browser hash is bumped, and Vite sends a `full-reload` that the Vitest + * tester iframes do not act on. The `@storybook/react` renderer already loaded + * in a live iframe still holds the old hash for its lazy + * `import("@storybook/react-dom-shim")`, so every story that iframe renders + * from then on fails with "Failed to fetch dynamically imported module" — and + * the next run is green, because the cache has caught up. `force` discards the + * cache up front so the scanner crawls every story entry before the browser + * opens: the same cold path CI takes on every PR. Cost: ~0.8s per run. + * + * Spread from `getViteBaseConfig()` rather than left to Vitest's project merge + * so the include/exclude the base set carries are the helper's own claim, and + * tested as such. + */ +export function getStorybookOptimizeDeps() { + return { + ...getViteBaseConfig().optimizeDeps, + force: true, + }; +} + /** * Dev-server optimizeDeps: rebuild from scratch each launch — no stale * pre-bundles carried across restarts (avoids 504 Outdated Optimize Dep). diff --git a/clients/web/src/test/integration/server/vite-base-config.test.ts b/clients/web/src/test/integration/server/vite-base-config.test.ts index 90c39abd..2fcb88d9 100644 --- a/clients/web/src/test/integration/server/vite-base-config.test.ts +++ b/clients/web/src/test/integration/server/vite-base-config.test.ts @@ -10,6 +10,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { clearViteDepsCache, + getStorybookOptimizeDeps, getViteBaseConfig, getViteDevOptimizeDeps, } from "../../../../server/vite-base-config.js"; @@ -54,6 +55,30 @@ describe("getViteDevOptimizeDeps", () => { }); }); +describe("getStorybookOptimizeDeps", () => { + it("forces a re-bundle on every run so a stale cache cannot re-optimize mid-run (#2340)", () => { + const config = getStorybookOptimizeDeps(); + expect(config.force).toBe(true); + }); + + it("keeps the base include and exclude sets", () => { + const config = getStorybookOptimizeDeps(); + const base = getViteBaseConfig().optimizeDeps; + expect(config.include).toEqual(base.include); + expect(config.exclude).toEqual(base.exclude); + expect(config.include).toContain("@modelcontextprotocol/client"); + }); + + it("does not carry the dev server's stale-request tolerance", () => { + // `ignoreOutdatedRequests` belongs to `vite dev`, where a full page reload + // follows a re-optimization; under Vitest a request for an outdated dep + // must fail loudly, since nothing reloads the tester iframe. + expect(getStorybookOptimizeDeps()).not.toHaveProperty( + "ignoreOutdatedRequests", + ); + }); +}); + describe("clearViteDepsCache", () => { let tempRoot: string; diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index d6fea376..ac7f541b 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -9,6 +9,7 @@ import { storybookTest } from "@storybook/addon-vitest/vitest-plugin"; import { playwright } from "@vitest/browser-playwright"; import { honoMiddlewarePlugin } from "./server/vite-hono-plugin"; import { + getStorybookOptimizeDeps, getViteBaseConfig, getViteDevOptimizeDeps, } from "./server/vite-base-config"; @@ -391,6 +392,10 @@ export default defineConfig(({ command }) => { configDir: path.join(dirname, ".storybook"), }), ], + // Re-bundled on every run rather than read from the cache — the + // stale-cache failure it prevents, and why `force` is the lever, are + // on the helper (#2340). + optimizeDeps: getStorybookOptimizeDeps(), test: { name: "storybook", // Vitest's default is 5000ms, which is the whole budget a play