Vite Monorepo Root Discovery#
Storybook's Vite tooling — builder-vite and addon-vitest — each perform their own root discovery and filesystem allowlist logic. These mechanisms work differently and both have known gaps in monorepo setups where the .storybook config lives inside a nested package rather than at the repository root.
Vite Builder Root Resolution#
commonConfig derives Vite's root as resolve(options.configDir, '..') — the parent directory of the .storybook folder. It then calls loadConfigFromFile with that projectRoot as the search base, using configFile: false to prevent double-loading.
The viteFinal preset hook is applied after commonConfig in both build and dev modes, giving main.ts a chance to further customize the assembled config.
Two plugins in storybook-config-plugin.ts run as part of every Vite build:
storybook:config-plugin(enforce:pre) — injectsstorybook,stories, andtestresolve conditions; adds theSTORYBOOK_env prefix; setspreserveSymlinksfor symlink-based monorepos .storybook:allow-storybook-dir(enforce:post) — conditionally appendsconfigDirtoserver.fs.allow.
Filesystem Allowlist (server.fs.allow)#
The storybook:allow-storybook-dir plugin adds the Storybook configDir to Vite's filesystem allowlist only when an allowlist already exists in the incoming config :
if (config?.server?.fs?.allow) {
config.server.fs.allow.push(options.configDir);
}
If there is no server.fs.allow, Vite allows access to anything inside its root by default, so adding configDir would unnecessarily tighten the restriction .
What is not covered: The plugin only ever adds the configDir itself. It does not discover or add:
- Ancestor
node_modulesdirectories above the Vite root - Workspace roots in monorepo layouts
This is the root cause of the monorepo gap described in the Known Gaps section below. Test coverage for this conditional behavior lives in vite-config.test.ts .
Vitest Manager: Config File Discovery#
When Storybook launches tests via the Test addon UI, VitestManager.startVitest() searches for the correct Vitest/Vite config file before calling createVitest. The search walks upward from the package root:
- Starting point:
dirname(resolve(configDir))— the parent of.storybook, i.e. the nearest package root. This ensures sub-package configs are found even whenprocess.cwd()is the monorepo root . - Stopping point:
getProjectRoot()— resolves the outermost project root via VCS markers (.git,.svn,.hg), lock files, or theSTORYBOOK_PROJECT_ROOTenvironment variable . - Preference: The walk prefers a config file whose content references
storybookTestor@storybook/addon-vitest. If none is found, it falls back to the first Vitest/Vite config encountered .
The config files searched, in priority order: vitest.workspace.{ts,js,json}, then vitest.config.{mts,mjs,cts,cjs,ts,tsx,js,jsx} and vite.config.{same extensions} .
createVitest is then called with root set to the directory containing the discovered config (or a fallback to firstVitestConfig || packageRoot || process.cwd()) .
addon-vitest Plugin: vitestRoot and Story Inclusion#
Inside the storybookTest Vite plugin, vitestRoot is resolved at config time from the first non-empty value in:
// Vitest resolves the story globs below against the root this plugin returns (via
// `viteFinal`), not the root it was invoked with. When those differ — a Vitest config
// above `configDir/..`, as in a monorepo — relativizing against the invoking root points
// every glob outside the project and silently matches no files.
finalOptions.vitestRoot =
testConfig?.dir ||
testConfig?.root ||
viteConfigFromStorybook.root ||
nonMutableInputConfig.root ||
process.cwd();
The precedence order is:
test.dir— explicitly configured test directory from the Vitest configtest.root— explicitly configured root from the Vitest configviteConfigFromStorybook.root— the root returned by the plugin viaviteFinal, which is set toresolve(configDir, '..'). This addresses the monorepo scenario where the Vitest config lives above the Storybook package: Vitest resolves story globs against the root this plugin returns, not the root it was invoked with, so relativizing against the invoking root would point every glob outside the project and silently match no files.config.root— the root Vitest was invoked with (directory of the Vitest config file)process.cwd()— current working directory fallback
Story glob patterns (from main.ts#stories) are then relativized to vitestRoot before being passed to Vitest's test.include .
The plugin also derives a commonConfig with root: resolve(finalOptions.configDir, '..') for the viteFinal preset call , mirroring the builder-vite behavior.
Known Gaps in Monorepo Setups#
Gap 1: node_modules unreachable when vitest.config.ts is in a library subdirectory#
Reported in issue #35578 for Angular workspaces but applies to any setup where vitest.config.ts lives inside packages/<lib>/ while node_modules is at the workspace root.
What happens: Vite scopes its dev server root to the directory containing vitest.config.ts. Anything outside that root — including workspace-root node_modules — is blocked by Vite's default filesystem policy, producing:
TypeError: Failed to fetch dynamically imported module:
http://localhost:63315/.../node_modules/@angular/compiler/fesm2022/compiler.mjs
Root cause: Neither storybookTest nor storybookAngularVitest set server.fs.allow to include ancestor node_modules directories. The storybook:allow-storybook-dir plugin only appends configDir when an allowlist already exists — it performs no workspace-root discovery .
Workaround: Manually add server.fs.allow pointing to the workspace root in the vitest.config.ts project config :
// vitest.config.ts (inside projects/my-lib/)
{
server: {
fs: {
allow: [path.resolve(dirname, '../..')] // workspace root
}
}
}
Gap 2: Vitest Config Above Storybook (RESOLVED)#
Fixed: The storybookTest plugin now includes viteConfigFromStorybook.root in its root resolution precedence order (between test.root and config.root). This ensures that story globs are relativized against the root the plugin returns via viteFinal, not the root Vitest was invoked with.
In monorepo setups where the Vitest config lives above the Storybook package, the two roots differ. Before this fix, globs relativized against the invoking root would resolve outside the project and match no story files. The plugin now correctly discovers all story files regardless of where the Vitest config is placed.
Gap 3: Path aliases not automatically bridged#
When vitest.config.ts is in a subdirectory and the user's tsconfig.json or Vite config at the workspace root defines path aliases, those aliases are not automatically resolved relative to the new Vitest root. Users must re-declare aliases in the project-level Vitest config or use extends: true carefully.
Key Files and References#
| File | Role |
|---|---|
builder-vite/src/vite-config.ts | Sets Vite root = parent of .storybook; assembles plugin chain |
builder-vite/src/plugins/storybook-config-plugin.ts | Resolve conditions, env prefix, conditional server.fs.allow |
addons/vitest/src/node/vitest-manager.ts | Upward config-file walk; createVitest root selection |
addons/vitest/src/vitest-plugin/index.ts | storybookTest plugin; vitestRoot resolution; story glob relativization |
core/src/common/utils/paths.ts | getProjectRoot() — VCS/lock-file-based root detection |
| Issue #35578 | Angular workspace node_modules unreachable — bug report with workaround |
Vite server.fs.allow docs | How Vite natively handles monorepo roots |