Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 23 additions & 3 deletions packages/nuxt-module/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,29 @@ export default defineNuxtModule<ModuleOptions>({

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)
})
})
},
})
2 changes: 2 additions & 0 deletions packages/nuxt-module/src/storybook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 26 additions & 7 deletions packages/storybook-addon/src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<symbol, Promise<ViteConfig> | 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) {
Expand Down Expand Up @@ -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(
Expand All @@ -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),
Expand Down
11 changes: 10 additions & 1 deletion playground/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,23 @@ 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',
],

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: {
Expand Down
26 changes: 21 additions & 5 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
],
})
49 changes: 41 additions & 8 deletions test/module-integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')
Expand All @@ -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)
Expand All @@ -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')
Expand Down
37 changes: 37 additions & 0 deletions test/playground.embedded.browser.ts
Original file line number Diff line number Diff line change
@@ -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 <NuxtWelcome />
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.
Loading