From 5720efb4f7c8e65814c11dd01b63e03096c8d336 Mon Sep 17 00:00:00 2001 From: Simon Cornforth Date: Fri, 12 Jun 2026 19:59:49 +0100 Subject: [PATCH 1/3] fix: resolve dev server deadlock when starting embedded Storybook The module awaited setupStorybook() inside Nuxt's listen hook, while @storybook-vue/nuxt's loadNuxtViteConfig() waited for the running Nuxt instance's vite:configResolved event - which cannot fire until the boot pipeline, blocked by that same listen hook, proceeds. Every `nuxt dev` with the module enabled hung on all requests. - module: capture the client Vite config during setup (before the event can fire) and share it with the framework via a well-known symbol - module: start Storybook from the listen hook without awaiting it - addon: consume the captured config instead of registering a late hook - start Storybook with ci: true so a busy port cannot block on an interactive prompt - e2e: cover the embedded (`nuxt dev`) path, previously untested Refs #993 Co-Authored-By: Claude Fable 5 --- packages/nuxt-module/src/module.ts | 26 ++++++++++++-- packages/nuxt-module/src/storybook.ts | 2 ++ packages/storybook-addon/src/preset.ts | 19 ++++++++-- playground/nuxt.config.ts | 11 +++++- playwright.config.ts | 26 +++++++++++--- test/module-integration.spec.ts | 49 +++++++++++++++++++++----- test/playground.embedded.browser.ts | 37 +++++++++++++++++++ 7 files changed, 150 insertions(+), 20 deletions(-) create mode 100644 test/playground.embedded.browser.ts diff --git a/packages/nuxt-module/src/module.ts b/packages/nuxt-module/src/module.ts index 7c6a5513..93e5e003 100644 --- a/packages/nuxt-module/src/module.ts +++ b/packages/nuxt-module/src/module.ts @@ -85,9 +85,29 @@ export default defineNuxtModule({ logger.verbose('🔌 Storybook Module Setup') - // Defer Storybook startup until Nuxt's HTTP server is ready - nuxt.hook('listen', async () => { - await setupStorybook(options, nuxt) + // Capture the resolved client Vite config now, while modules are loading: + // by the time Storybook starts (after the listen hook) the event may + // already have fired, so @storybook-vue/nuxt cannot reliably register + // this hook itself (#993). + const viteConfigPromise = new Promise((resolve) => { + nuxt.hook('vite:configResolved', (config, { isClient }) => { + if (isClient) resolve(config) + }) + }) + // Hand the promise to @storybook-vue/nuxt's loadNuxtViteConfig, which + // runs in the same process and reads it off the shared Nuxt instance. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(nuxt as any)[Symbol.for('@storybook-vue/nuxt:vite-config-promise')] = + viteConfigPromise + + // Defer Storybook startup until Nuxt's HTTP server is ready, but do not + // await it: Nuxt's boot pipeline waits for listen-hook handlers, while + // Storybook's preview build waits for the Vite config above — awaiting + // here deadlocks both servers (#993). + nuxt.hook('listen', () => { + setupStorybook(options, nuxt).catch((err: unknown) => { + logger.error('Failed to start Storybook', err) + }) }) }, }) diff --git a/packages/nuxt-module/src/storybook.ts b/packages/nuxt-module/src/storybook.ts index 7e34caa9..60ec5a4f 100644 --- a/packages/nuxt-module/src/storybook.ts +++ b/packages/nuxt-module/src/storybook.ts @@ -64,6 +64,8 @@ export async function setupStorybook(options: ModuleOptions, nuxt: Nuxt) { port: storybookServerPort, configDir, configType: 'DEVELOPMENT', + // Embedded programmatic start: never block on interactive prompts + ci: true, cache: storybookCache, // Don't check for storybook updates (we're using the latest version) versionUpdates: false, diff --git a/packages/storybook-addon/src/preset.ts b/packages/storybook-addon/src/preset.ts index 5271ebb6..60baf0ca 100644 --- a/packages/storybook-addon/src/preset.ts +++ b/packages/storybook-addon/src/preset.ts @@ -81,10 +81,23 @@ async function loadNuxtViteConfig(root: string | undefined) { let nuxt = tryUseNuxt() if (nuxt) { - // Nuxt is already started in the current process (i.e. in dev mode) - // We assume that we are called from the Nuxt module, which means that - // Nuxt is in the "load module" state and we can access the Vite config later via the hook + // Nuxt is already started in the current process (i.e. in dev mode, + // started from @nuxtjs/storybook). The module captured the resolved + // client Vite config into this promise during module setup — at this + // point the vite:configResolved event may already have fired, so + // registering a hook here would wait forever (#993). const nuxtRes = nuxt + const viteConfigPromise = ( + nuxt as unknown as Record | undefined> + )[Symbol.for('@storybook-vue/nuxt:vite-config-promise')] + if (viteConfigPromise) { + return viteConfigPromise.then((viteConfig) => ({ + viteConfig, + nuxt: nuxtRes, + })) + } + // Fallback: Nuxt is present but didn't stash the config promise, so it + // is still in the "load module" state and the hook fires later. return new Promise<{ viteConfig: ViteConfig; nuxt: Nuxt }>((resolve) => { nuxtRes.hook('vite:configResolved', (config, { isClient }) => { if (isClient) { diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 4c1ae8bc..5c6d59ac 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -3,7 +3,11 @@ export default defineNuxtConfig({ compatibilityDate: '2024-11-01', devtools: { enabled: true }, modules: [ - '../packages/nuxt-module/src/module', + // Load the module from the built package (dist) rather than the raw + // TypeScript source: the embedded Storybook startup hangs when the module + // is loaded through jiti's TS transform, and users only ever get dist. + // Run `pnpm build` before `pnpm dev` / the e2e suite (CI already does). + '@nuxtjs/storybook', '@nuxt/test-utils/module', '@nuxtjs/i18n', ], @@ -11,6 +15,11 @@ export default defineNuxtConfig({ storybook: { // Very verbose logs for debugging logLevel: Number.POSITIVE_INFINITY, + // Let the e2e setup pin the embedded Storybook port so it cannot collide + // with the standalone instance (see playwright.config.ts) + ...(process.env.STORYBOOK_PORT + ? { port: Number(process.env.STORYBOOK_PORT) } + : {}), }, i18n: { diff --git a/playwright.config.ts b/playwright.config.ts index 5fbb8c6f..783e6716 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -72,9 +72,25 @@ export default defineConfig({ ], /* Run your local dev server before starting the tests */ - webServer: { - command: 'pnpm playground:storybook:dev', - url: 'http://127.0.0.1:6006', - reuseExistingServer: !process.env.CI, - }, + webServer: [ + { + command: 'pnpm playground:storybook:dev', + url: 'http://127.0.0.1:6006', + reuseExistingServer: !process.env.CI, + }, + // Embedded mode: `nuxt dev` with the module starting Storybook itself + // (regression coverage for #993). Readiness is checked on the embedded + // Storybook, which only responds once the module has started it. + // Nuxt's port is passed as a flag (not the PORT env var) because + // Storybook's dev server also reads PORT and would try to bind it. + { + command: 'pnpm --filter=./playground exec nuxt dev --port 3100', + url: 'http://127.0.0.1:6016', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + STORYBOOK_PORT: '6016', + }, + }, + ], }) diff --git a/test/module-integration.spec.ts b/test/module-integration.spec.ts index 5f15e0b7..85260f8e 100644 --- a/test/module-integration.spec.ts +++ b/test/module-integration.spec.ts @@ -3,9 +3,12 @@ import { describe, expect, it } from 'vitest' /** * Unit tests for the @nuxtjs/storybook module setup behavior. * - * These tests verify that the module properly defers Storybook startup - * to Nuxt's 'listen' hook, preventing the timing issue where Storybook - * would start before Nuxt's HTTP server was ready (causing proxy EAGAIN errors). + * These tests verify that the module starts Storybook from Nuxt's 'listen' + * hook WITHOUT awaiting it, and that the client Vite config is captured + * during module setup. Awaiting Storybook inside the listen hook deadlocks + * the dev server: Nuxt's boot pipeline waits for the hook handler while + * Storybook's preview build waits for Nuxt's vite:configResolved event, + * which only fires once boot proceeds (#993). * * We test the module.ts file directly by reading its source and verifying * the hook registration pattern. This is simpler than mocking the full @@ -16,7 +19,7 @@ import { describe, expect, it } from 'vitest' * of Storybook startup, use Playwright tests instead. */ describe('storybook module setup', () => { - it('module uses listen hook to defer storybook startup', async () => { + it('module starts storybook from the listen hook without blocking it', async () => { // Read the actual module source to verify the pattern const fs = await import('node:fs/promises') const path = await import('pathe') @@ -26,14 +29,20 @@ describe('storybook module setup', () => { 'utf-8', ) - // The fix pattern: nuxt.hook('listen', ...) should be used instead of - // calling setupStorybook directly in setup() + // Storybook startup is deferred to nuxt.hook('listen', ...) instead of + // running directly in setup() expect(moduleSource).toContain("nuxt.hook('listen'") - // setupStorybook should be called inside the listen hook callback + // setupStorybook is called inside the listen hook callback, but must NOT + // be awaited: that blocks Nuxt's boot pipeline and deadlocks against + // Storybook waiting for the Vite config (#993) expect(moduleSource).toMatch( - /nuxt\.hook\s*\(\s*['"]listen['"]\s*,\s*async\s*\(\)\s*=>\s*\{\s*await\s+setupStorybook/, + /nuxt\.hook\s*\(\s*['"]listen['"]\s*,\s*\(\)\s*=>\s*\{\s*setupStorybook/, ) + expect(moduleSource).not.toMatch(/await\s+setupStorybook/) + + // The fire-and-forget call must still surface failures + expect(moduleSource).toMatch(/setupStorybook\([^)]*\)\.catch/) // setupStorybook should NOT be called directly in the setup function // (outside of the listen hook) @@ -50,6 +59,30 @@ describe('storybook module setup', () => { expect(beforeHook).not.toContain('setupStorybook(') }) + it('module captures the vite config before the listen hook', async () => { + const fs = await import('node:fs/promises') + const path = await import('pathe') + + const moduleSource = await fs.readFile( + path.resolve(__dirname, '../packages/nuxt-module/src/module.ts'), + 'utf-8', + ) + + // The vite:configResolved capture must be registered during module setup, + // before the listen hook: Storybook starts after 'listen' fires, at which + // point the event may already have passed (#993) + const capturePos = moduleSource.indexOf("nuxt.hook('vite:configResolved'") + const listenPos = moduleSource.indexOf("nuxt.hook('listen'") + expect(capturePos).toBeGreaterThan(-1) + expect(listenPos).toBeGreaterThan(capturePos) + + // The captured promise is shared with @storybook-vue/nuxt via a + // well-known symbol on the Nuxt instance + expect(moduleSource).toContain( + "Symbol.for('@storybook-vue/nuxt:vite-config-promise')", + ) + }) + it('module checks for __STORYBOOK__ env before registering hook', async () => { const fs = await import('node:fs/promises') const path = await import('pathe') diff --git a/test/playground.embedded.browser.ts b/test/playground.embedded.browser.ts new file mode 100644 index 00000000..f9bb6863 --- /dev/null +++ b/test/playground.embedded.browser.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test' + +/** + * Embedded mode: the @nuxtjs/storybook module starts Storybook from inside + * `nuxt dev` (see the second webServer entry in playwright.config.ts). + * + * Regression coverage for #993: starting Storybook used to deadlock Nuxt's + * boot pipeline, leaving the Nuxt app hanging on every request while only + * Storybook's manager UI came up. + */ + +test('nuxt app responds while storybook runs embedded', async ({ page }) => { + await page.goto('http://localhost:3100/') + + // The playground app renders + await expect(page).toHaveTitle('Welcome to Nuxt!') + await expect(page.getByRole('heading', { name: 'Get started' })).toBeVisible() +}) + +test('embedded storybook renders the story example', async ({ page }) => { + await page.goto( + 'http://localhost:6016/iframe.html?viewMode=story&id=example-nuxtwelcome--nuxt-welcome-story', + ) + await page.locator('#storybook-root').waitFor() + await page.locator('.sb-preparing-story').waitFor({ state: 'hidden' }) + + await expect( + page.locator('#storybook-root').getByRole('heading', { + exact: true, + name: 'Welcome Nuxt to Storybook', + }), + ).toBeVisible() +}) + +// Note: the docs example is intentionally not tested in embedded mode yet. +// The embedded story index is missing all addon-docs entries (autodocs and +// MDX) — a separate bug from the #993 deadlock, tracked independently. From d5ecc3dd6e378fdab2dadb39a74ab6985ae89a5e Mon Sep 17 00:00:00 2001 From: Simon Cornforth Date: Fri, 12 Jun 2026 20:17:18 +0100 Subject: [PATCH 2/3] fix: stub-build packages before playground prepare The playground now loads @nuxtjs/storybook from the built package, so `nuxt prepare` during install needs at least a stub dist to exist. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8413709f..c3dcba0a 100755 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "lint:prettier": "prettier --check .", "lint:fix": "pnpm lint:oxlint --fix && pnpm lint:prettier --write", "release-notes": "pnpm changelogithub", - "prepare": "pnpm run --filter=./playground/** prepare", + "prepare": "pnpm run --recursive --filter=./packages/* --parallel dev:prepare && pnpm run --filter=./playground/** prepare", "docs:dev": "pnpm run --filter=./docs/** dev", "docs:build": "pnpm run --filter=./docs/** build", "test": "vitest run", From 127301cae4c1a5e1eb2a16419f2fdee5e64b82e4 Mon Sep 17 00:00:00 2001 From: Simon Cornforth Date: Fri, 12 Jun 2026 22:25:16 +0100 Subject: [PATCH 3/3] fix: do not mutate the app's live vite config when embedded vite's mergeConfig reuses nested objects by reference when a key exists on only one side of the merge. In embedded mode the merge input is the running app's resolved config, so writing noDiscovery and pushing include entries poisoned the app's own dep optimizer. Clone the objects this function writes to. Verified against the #993 reproduction with an instrumented module: optimizeDeps.noDiscovery no longer flips on the live config after Storybook starts. Co-Authored-By: Claude Fable 5 --- packages/storybook-addon/src/preset.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/storybook-addon/src/preset.ts b/packages/storybook-addon/src/preset.ts index 60baf0ca..8941c719 100644 --- a/packages/storybook-addon/src/preset.ts +++ b/packages/storybook-addon/src/preset.ts @@ -179,7 +179,12 @@ function mergeViteConfig( ): ViteConfig { const extendedConfig: ViteConfig = mergeConfig(nuxtConfig, storybookConfig) - const plugins = extendedConfig.plugins || [] + // mergeConfig reuses nested objects by reference when a key exists on only + // one side. In embedded mode nuxtConfig is the app's LIVE resolved config, + // so mutating those shared objects below would poison the running dev + // server (e.g. its dep optimizer), breaking the app's own asset serving + // (#993). Clone everything this function writes to. + const plugins = [...(extendedConfig.plugins || [])] // Find the index of the plugin with name 'vite:vue' const index = plugins.findIndex( @@ -200,9 +205,10 @@ function mergeViteConfig( // Storybook adds 'vue' as dependency that should be optimized, but nuxt explicitly excludes it from pre-bundling // Prioritize `optimizeDeps.exclude`. If same dep is in `include` and `exclude`, remove it from `include` - extendedConfig.optimizeDeps = extendedConfig.optimizeDeps || {} - extendedConfig.optimizeDeps.include = - extendedConfig.optimizeDeps.include || [] + extendedConfig.optimizeDeps = { + ...extendedConfig.optimizeDeps, + include: [...(extendedConfig.optimizeDeps?.include || [])], + } extendedConfig.optimizeDeps.include = extendedConfig.optimizeDeps.include.filter( (dep) => !extendedConfig.optimizeDeps?.exclude?.includes(dep),