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", 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..8941c719 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) { @@ -166,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( @@ -187,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), 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.