From d6f94a2772745d3893ebf3995eaeeaf05d6b9188 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Mon, 6 Oct 2025 13:32:09 +0200 Subject: [PATCH 01/16] mark deprecations of yarn pnp --- code/core/src/builder-manager/index.ts | 1 + code/core/src/cli/detect.ts | 1 + .../common/js-package-manager/JsPackageManager.ts | 1 + code/core/src/common/js-package-manager/PNPMProxy.ts | 1 + .../core/src/common/js-package-manager/Yarn2Proxy.ts | 1 + .../src/common/utils/strip-abs-node-modules-path.ts | 1 + code/core/src/core-server/typings.d.ts | 1 + code/core/src/telemetry/get-framework-info.ts | 1 + code/core/src/telemetry/get-package-manager-info.ts | 1 + .../nextjs/src/swc/next-swc-loader-patch.ts | 1 + .../automigrate/fixes/wrap-getAbsolutePath-utils.ts | 1 + code/lib/cli-storybook/src/bin/run.ts | 1 + code/lib/cli-storybook/src/sandbox-templates.ts | 7 ------- code/lib/create-storybook/src/bin/run.ts | 1 + .../src/generators/REACT_SCRIPTS/index.ts | 1 + .../create-storybook/src/generators/baseGenerator.ts | 2 ++ code/lib/create-storybook/src/generators/types.ts | 1 + code/lib/create-storybook/src/initiate.ts | 12 +++++++++++- code/presets/create-react-app/src/index.ts | 3 +++ code/renderers/react/src/preset.ts | 2 ++ scripts/sandbox/utils/yarn.ts | 1 + scripts/utils/yarn.ts | 1 + test-storybooks/yarn-pnp/.yarnrc.yml | 2 ++ 23 files changed, 37 insertions(+), 8 deletions(-) diff --git a/code/core/src/builder-manager/index.ts b/code/core/src/builder-manager/index.ts index 462d5dc6263c..d67a290035dd 100644 --- a/code/core/src/builder-manager/index.ts +++ b/code/core/src/builder-manager/index.ts @@ -4,6 +4,7 @@ import { stringifyProcessEnvs } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; import { globalExternals } from '@fal-works/esbuild-plugin-global-externals'; +// TODO: Remove in SB11 import { pnpPlugin } from '@yarnpkg/esbuild-plugin-pnp'; import { resolveModulePath } from 'exsolve'; import { join, parse } from 'pathe'; diff --git a/code/core/src/cli/detect.ts b/code/core/src/cli/detect.ts index c07dc8c119cc..603e3e1bcb37 100644 --- a/code/core/src/cli/detect.ts +++ b/code/core/src/cli/detect.ts @@ -170,6 +170,7 @@ export function isStorybookInstantiated(configDir = resolve(process.cwd(), '.sto return existsSync(configDir); } +// TODO: Remove in SB11 export async function detectPnp() { return !!find.any(['.pnp.js', '.pnp.cjs']); } diff --git a/code/core/src/common/js-package-manager/JsPackageManager.ts b/code/core/src/common/js-package-manager/JsPackageManager.ts index ae1850a508d4..ed4bdd86157b 100644 --- a/code/core/src/common/js-package-manager/JsPackageManager.ts +++ b/code/core/src/common/js-package-manager/JsPackageManager.ts @@ -671,6 +671,7 @@ export abstract class JsPackageManager { return execaProcess; } + // TODO: Remove pnp compatibility code in SB11 /** Returns the installed (within node_modules or pnp zip) version of a specified package */ public async getInstalledVersion(packageName: string): Promise { const cacheKey = packageName; diff --git a/code/core/src/common/js-package-manager/PNPMProxy.ts b/code/core/src/common/js-package-manager/PNPMProxy.ts index 8c7d83699480..b6c37b27f33a 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.ts @@ -142,6 +142,7 @@ export class PNPMProxy extends JsPackageManager { } } + // TODO: Remove pnp compatibility code in SB11 public async getModulePackageJSON(packageName: string): Promise { const pnpapiPath = find.any(['.pnp.js', '.pnp.cjs'], { cwd: this.primaryPackageJson.operationDir, diff --git a/code/core/src/common/js-package-manager/Yarn2Proxy.ts b/code/core/src/common/js-package-manager/Yarn2Proxy.ts index d0eef925baa8..14fe5bc75cf3 100644 --- a/code/core/src/common/js-package-manager/Yarn2Proxy.ts +++ b/code/core/src/common/js-package-manager/Yarn2Proxy.ts @@ -149,6 +149,7 @@ export class Yarn2Proxy extends JsPackageManager { } } + // TODO: Remove pnp compatibility code in SB11 async getModulePackageJSON(packageName: string): Promise { const pnpapiPath = find.any(['.pnp.js', '.pnp.cjs'], { cwd: this.cwd, diff --git a/code/core/src/common/utils/strip-abs-node-modules-path.ts b/code/core/src/common/utils/strip-abs-node-modules-path.ts index 61eecad05559..0c7be66e1d00 100644 --- a/code/core/src/common/utils/strip-abs-node-modules-path.ts +++ b/code/core/src/common/utils/strip-abs-node-modules-path.ts @@ -9,6 +9,7 @@ function normalizePath(id: string) { // We need to convert from an absolute path, to a traditional node module import path, // so that vite can correctly pre-bundle/optimize export function stripAbsNodeModulesPath(absPath: string) { + // TODO: Evaluate if this is correct after removing pnp compatibility code in SB11 // TODO: Evaluate if searching for node_modules in a yarn pnp environment is correct const splits = absPath.split(`node_modules${sep}`); // Return everything after the final "node_modules/" diff --git a/code/core/src/core-server/typings.d.ts b/code/core/src/core-server/typings.d.ts index e20ddb71c073..27369cd336c9 100644 --- a/code/core/src/core-server/typings.d.ts +++ b/code/core/src/core-server/typings.d.ts @@ -1,4 +1,5 @@ declare module 'lazy-universal-dotenv'; +// TODO: Remove in SB11 declare module 'pnp-webpack-plugin'; declare module '@aw-web-design/x-default-browser'; declare module '@discoveryjs/json-ext'; diff --git a/code/core/src/telemetry/get-framework-info.ts b/code/core/src/telemetry/get-framework-info.ts index 0216e13bf4db..d114501481f0 100644 --- a/code/core/src/telemetry/get-framework-info.ts +++ b/code/core/src/telemetry/get-framework-info.ts @@ -63,6 +63,7 @@ export async function getFrameworkInfo(mainConfig: StorybookConfig) { const builder = findMatchingPackage(frameworkPackageJson, knownBuilders); const renderer = findMatchingPackage(frameworkPackageJson, knownRenderers); + // TODO: Evaluate if this is correct after removing pnp compatibility code in SB11 // parse framework name and strip off pnp paths etc. const sanitizedFrameworkName = getFrameworkPackageName(rawName); const frameworkOptions = diff --git a/code/core/src/telemetry/get-package-manager-info.ts b/code/core/src/telemetry/get-package-manager-info.ts index 11ab6fdf74d9..269ffd6b7b6c 100644 --- a/code/core/src/telemetry/get-package-manager-info.ts +++ b/code/core/src/telemetry/get-package-manager-info.ts @@ -11,6 +11,7 @@ export const getPackageManagerInfo = async () => { return undefined; } + // TODO: Evaluate if this is correct after removing pnp compatibility code in SB11 let nodeLinker: 'node_modules' | 'pnp' | 'pnpm' | 'isolated' | 'hoisted' = 'node_modules'; if (packageManagerType.name === 'yarn') { diff --git a/code/frameworks/nextjs/src/swc/next-swc-loader-patch.ts b/code/frameworks/nextjs/src/swc/next-swc-loader-patch.ts index a4a54b119b52..ec4ec7b35141 100644 --- a/code/frameworks/nextjs/src/swc/next-swc-loader-patch.ts +++ b/code/frameworks/nextjs/src/swc/next-swc-loader-patch.ts @@ -153,6 +153,7 @@ export function pitch(this: any) { const callback = this.async(); (async () => { if ( + // TODO: Evaluate if this is correct after removing pnp compatibility code in SB11 // TODO: investigate swc file reading in PnP mode? !process.versions.pnp && !EXCLUDED_PATHS.test(this.resourcePath) && diff --git a/code/lib/cli-storybook/src/automigrate/fixes/wrap-getAbsolutePath-utils.ts b/code/lib/cli-storybook/src/automigrate/fixes/wrap-getAbsolutePath-utils.ts index e4ffc1144d4f..2d14ac1d4302 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/wrap-getAbsolutePath-utils.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/wrap-getAbsolutePath-utils.ts @@ -2,6 +2,7 @@ import { types as t } from 'storybook/internal/babel'; import type { ConfigFile } from 'storybook/internal/csf-tools'; const PREFERRED_GET_ABSOLUTE_PATH_WRAPPER_NAME = 'getAbsolutePath'; +// TODO: Remove in SB11 const ALTERNATIVE_GET_ABSOLUTE_PATH_WRAPPER_NAME = 'wrapForPnp'; /** diff --git a/code/lib/cli-storybook/src/bin/run.ts b/code/lib/cli-storybook/src/bin/run.ts index 04232bb00865..7299b01cff56 100644 --- a/code/lib/cli-storybook/src/bin/run.ts +++ b/code/lib/cli-storybook/src/bin/run.ts @@ -80,6 +80,7 @@ command('init') .option('-f --force', 'Force add Storybook') .option('-s --skip-install', 'Skip installing deps') .option('--package-manager ', 'Force package manager for installing deps') + // TODO: Remove in SB11 .option('--use-pnp', 'Enable PnP mode for Yarn 2+') .option('-p --parser ', 'jscodeshift parser') .option('-t --type ', 'Add Storybook for a specific project type') diff --git a/code/lib/cli-storybook/src/sandbox-templates.ts b/code/lib/cli-storybook/src/sandbox-templates.ts index 8f04b42f074b..6fed03893a6c 100644 --- a/code/lib/cli-storybook/src/sandbox-templates.ts +++ b/code/lib/cli-storybook/src/sandbox-templates.ts @@ -728,13 +728,6 @@ const internalTemplates = { isInternal: true, skipTasks: ['bench', 'vitest-integration'], }, - // 'internal/pnp': { - // ...baseTemplates['cra/default-ts'], - // name: 'PNP (cra/default-ts)', - // script: 'yarn create react-app . --use-pnp', - // isInternal: true, - // inDevelopment: true, - // }, } satisfies Record<`internal/${string}`, Template & { isInternal: true }>; const benchTemplates = { diff --git a/code/lib/create-storybook/src/bin/run.ts b/code/lib/create-storybook/src/bin/run.ts index 6d9ad4be7452..849d33121ec7 100644 --- a/code/lib/create-storybook/src/bin/run.ts +++ b/code/lib/create-storybook/src/bin/run.ts @@ -31,6 +31,7 @@ const createStorybookProgram = program '--package-manager ', 'Force package manager for installing deps' ) + // TODO: Remove in SB11 .option('--use-pnp', 'Enable pnp mode for Yarn 2+') .option('-p --parser ', 'jscodeshift parser') .option('-t --type ', 'Add Storybook for a specific project type') diff --git a/code/lib/create-storybook/src/generators/REACT_SCRIPTS/index.ts b/code/lib/create-storybook/src/generators/REACT_SCRIPTS/index.ts index d68c61a9ec9b..2da9f60d5cca 100644 --- a/code/lib/create-storybook/src/generators/REACT_SCRIPTS/index.ts +++ b/code/lib/create-storybook/src/generators/REACT_SCRIPTS/index.ts @@ -46,6 +46,7 @@ const generator: Generator = async (packageManager, npmOptions, options) => { const extraPackages = []; extraPackages.push('webpack'); + // TODO: Evaluate if this is correct after removing pnp compatibility code in SB11 // Miscellaneous dependency to add to be sure Storybook + CRA is working fine with Yarn PnP mode extraPackages.push('prop-types'); diff --git a/code/lib/create-storybook/src/generators/baseGenerator.ts b/code/lib/create-storybook/src/generators/baseGenerator.ts index c3df08c25dfd..677d57de5b42 100644 --- a/code/lib/create-storybook/src/generators/baseGenerator.ts +++ b/code/lib/create-storybook/src/generators/baseGenerator.ts @@ -126,6 +126,7 @@ const applyAddonGetAbsolutePathWrapper = (pkg: string | { name: string }) => { const getFrameworkDetails = ( renderer: SupportedRenderers, builder: Builder, + // TODO: Remove in SB11 pnp: boolean, language: SupportedLanguage, framework?: SupportedFrameworks, @@ -397,6 +398,7 @@ export async function baseGenerator( } if (addMainFile) { + // TODO: Evaluate if this is correct after removing pnp compatibility code in SB11 const prefixes = shouldApplyRequireWrapperOnPackageNames ? [ 'import { dirname } from "path"', diff --git a/code/lib/create-storybook/src/generators/types.ts b/code/lib/create-storybook/src/generators/types.ts index ff6b8eb6cbd6..89a661a50312 100644 --- a/code/lib/create-storybook/src/generators/types.ts +++ b/code/lib/create-storybook/src/generators/types.ts @@ -7,6 +7,7 @@ export type GeneratorOptions = { language: SupportedLanguage; builder: Builder; linkable: boolean; + // TODO: Remove in SB11 pnp: boolean; projectType: ProjectType; frameworkPreviewParts?: FrameworkPreviewParts; diff --git a/code/lib/create-storybook/src/initiate.ts b/code/lib/create-storybook/src/initiate.ts index e073bfe36946..3e16e26845f9 100644 --- a/code/lib/create-storybook/src/initiate.ts +++ b/code/lib/create-storybook/src/initiate.ts @@ -26,7 +26,7 @@ import { versions, } from 'storybook/internal/common'; import { withTelemetry } from 'storybook/internal/core-server'; -import { logger } from 'storybook/internal/node-logger'; +import { deprecate, logger } from 'storybook/internal/node-logger'; import { NxProjectDetectedError } from 'storybook/internal/server-errors'; import { telemetry } from 'storybook/internal/telemetry'; @@ -83,7 +83,17 @@ const installStorybook = async ( }; const language = await detectLanguage(packageManager as any); + + // TODO: Evaluate if this is correct after removing pnp compatibility code in SB11 const pnp = await detectPnp(); + if (pnp) { + deprecate(dedent` + As of Storybook 10.0, PnP is deprecated. + If you are using PnP, you can continue to use Storybook 10.0, but we recommend migrating to a different package manager or linker-mode. + + In future versions, PnP compatibility will be removed. + `); + } const generatorOptions: GeneratorOptions = { language, diff --git a/code/presets/create-react-app/src/index.ts b/code/presets/create-react-app/src/index.ts index 3dbee4f797e1..469d46f95fd7 100644 --- a/code/presets/create-react-app/src/index.ts +++ b/code/presets/create-react-app/src/index.ts @@ -2,6 +2,7 @@ import { dirname, join, relative } from 'node:path'; import { logger } from 'storybook/internal/node-logger'; +// TODO: Remove in SB11 import PnpWebpackPlugin from 'pnp-webpack-plugin'; import type { Configuration, RuleSetRule, WebpackPluginInstance } from 'webpack'; @@ -28,6 +29,7 @@ type ResolveLoader = Configuration['resolveLoader']; // This loader is shared by both the `managerWebpack` and `webpack` functions. const resolveLoader: ResolveLoader = { modules: ['node_modules', join(REACT_SCRIPTS_PATH, 'node_modules')], + // TODO: Remove in SB11 plugins: [PnpWebpackPlugin.moduleLoader(module)], }; @@ -127,6 +129,7 @@ const webpack = async ( join(REACT_SCRIPTS_PATH, 'node_modules'), ...getModulePath(CWD), ], + // TODO: Remove in SB11 plugins: [PnpWebpackPlugin as any], // manual copy from builder-webpack because defaults are disabled in this CRA preset conditionNames: [ diff --git a/code/renderers/react/src/preset.ts b/code/renderers/react/src/preset.ts index bc157cfd14a8..f4e7d94dd2ca 100644 --- a/code/renderers/react/src/preset.ts +++ b/code/renderers/react/src/preset.ts @@ -36,6 +36,8 @@ export const previewAnnotations: PresetProperty<'previewAnnotations'> = async ( ); }; +// TODO: Evaluate if this is correct after removing pnp compatibility code in SB11 + /** * Try to resolve react and react-dom from the root node_modules of the project addon-docs uses this * to alias react and react-dom to the project's version when possible If the user doesn't have an diff --git a/scripts/sandbox/utils/yarn.ts b/scripts/sandbox/utils/yarn.ts index 4b209b2a3352..ed00f2128b9a 100644 --- a/scripts/sandbox/utils/yarn.ts +++ b/scripts/sandbox/utils/yarn.ts @@ -6,6 +6,7 @@ import { runCommand } from '../generate'; interface SetupYarnOptions { cwd: string; + // TODO: Evaluate if this is correct after removing pnp compatibility code in SB11 pnp?: boolean; version?: 'berry' | 'classic'; } diff --git a/scripts/utils/yarn.ts b/scripts/utils/yarn.ts index 319c6411881f..97a7693b289b 100644 --- a/scripts/utils/yarn.ts +++ b/scripts/utils/yarn.ts @@ -49,6 +49,7 @@ export const addPackageResolutions = async ({ cwd, dryRun }: YarnOptions) => { export const installYarn2 = async ({ cwd, dryRun, debug }: YarnOptions) => { await rm(join(cwd, '.yarnrc.yml'), { force: true }).catch(() => {}); + // TODO: Remove in SB11 const pnpApiExists = await pathExists(join(cwd, '.pnp.cjs')); const command = [ diff --git a/test-storybooks/yarn-pnp/.yarnrc.yml b/test-storybooks/yarn-pnp/.yarnrc.yml index 7c3d8326a917..eeb117434a1e 100644 --- a/test-storybooks/yarn-pnp/.yarnrc.yml +++ b/test-storybooks/yarn-pnp/.yarnrc.yml @@ -1,3 +1,5 @@ +# TODO: Remove this whole test-storybooks/yarn-pnp directory in SB11 + yarnPath: ../../.yarn/releases/yarn-4.10.3.cjs nodeLinker: pnp From 51c0518d870da30dddb472d5e7d4218ad5c0f9e4 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Mon, 6 Oct 2025 13:42:13 +0200 Subject: [PATCH 02/16] add runtime check warning users that pnp support is going away in future versions --- code/core/src/core-server/build-dev.ts | 12 ++++++++++++ code/core/src/types/modules/core-common.ts | 1 + 2 files changed, 13 insertions(+) diff --git a/code/core/src/core-server/build-dev.ts b/code/core/src/core-server/build-dev.ts index 9c1b7a3c635a..750920a0cc6f 100644 --- a/code/core/src/core-server/build-dev.ts +++ b/code/core/src/core-server/build-dev.ts @@ -24,6 +24,7 @@ import prompts from 'prompts'; import invariant from 'tiny-invariant'; import { dedent } from 'ts-dedent'; +import { detectPnp } from '../cli/detect'; import { resolvePackageDir } from '../shared/utils/module'; import { storybookDevServer } from './dev-server'; import { buildOrThrow } from './utils/build-or-throw'; @@ -94,6 +95,17 @@ export async function buildDevStandalone( options.outputDir = outputDir; options.serverChannelUrl = getServerChannelUrl(port, options); + // TODO: Remove in SB11 + options.pnp = await detectPnp(); + if (options.pnp) { + deprecate(dedent` + As of Storybook 10.0, PnP is deprecated. + If you are using PnP, you can continue to use Storybook 10.0, but we recommend migrating to a different package manager or linker-mode. + + In future versions, PnP compatibility will be removed. + `); + } + const config = await loadMainConfig(options); const { framework } = config; const corePresets = []; diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index d31bf15209b9..eecdef490efc 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -152,6 +152,7 @@ export type PackageJson = PackageJsonFromTypeFest & Record; // TODO: This could be exported to the outside world and used in `options.ts` file of each `@storybook/APP` // like it's described in docs/api/new-frameworks.md export interface LoadOptions { + pnp?: boolean; packageJson?: PackageJson; outputDir?: string; configDir?: string; From 9ec584f9ce7e6e3754b719e9ac2858e64a0f56ef Mon Sep 17 00:00:00 2001 From: 404Dealer <171453009+404Dealer@users.noreply.github.com> Date: Fri, 17 Oct 2025 17:42:30 -0500 Subject: [PATCH 03/16] fix(a11y): persist tab/highlight across docs navigation (#32634) --- .../a11y/src/components/A11yContext.test.tsx | 140 +++++++++++++++++- .../a11y/src/components/A11yContext.tsx | 54 ++++++- code/addons/a11y/src/constants.ts | 1 + 3 files changed, 187 insertions(+), 8 deletions(-) diff --git a/code/addons/a11y/src/components/A11yContext.test.tsx b/code/addons/a11y/src/components/A11yContext.test.tsx index 3496ba2cb100..729e0d409dda 100644 --- a/code/addons/a11y/src/components/A11yContext.test.tsx +++ b/code/addons/a11y/src/components/A11yContext.test.tsx @@ -13,7 +13,8 @@ import { import type { AxeResults } from 'axe-core'; import * as api from 'storybook/manager-api'; -import { EVENTS } from '../constants'; +import { EVENTS, UI_STATE_ID } from '../constants'; +import { RuleType } from '../types'; import { A11yContextProvider, useA11yContext } from './A11yContext'; vi.mock('storybook/manager-api'); @@ -333,4 +334,141 @@ describe('A11yContext', () => { expect(emit).toHaveBeenCalledWith(EVENTS.MANUAL, storyId, expect.any(Object)); }); + + it('should persist highlighted state across provider remounts', () => { + const store = new Map(); + mockedApi.useAddonState.mockImplementation((id: string, defaultState: any) => { + if (!store.has(id)) { + store.set(id, defaultState); + } + const [state, setState] = React.useState(store.get(id)); + const setAndStore = (next: any) => { + const value = typeof next === 'function' ? next(store.get(id)) : next; + store.set(id, value); + setState(value); + }; + return [state, setAndStore] as any; + }); + + const Component = () => { + const { highlighted, toggleHighlight } = useA11yContext(); + return ( + + ); + }; + + const { getByTestId, unmount } = render( + + + + ); + + expect(getByTestId('toggle').textContent).toBe('false'); + act(() => getByTestId('toggle').click()); + expect(getByTestId('toggle').textContent).toBe('true'); + + unmount(); + const second = render( + + + + ); + + expect(second.getByTestId('toggle').textContent).toBe('true'); + }); + + it('should persist selected tab across provider remounts', () => { + const store = new Map(); + mockedApi.useAddonState.mockImplementation((id: string, defaultState: any) => { + if (!store.has(id)) { + store.set(id, defaultState); + } + const [state, setState] = React.useState(store.get(id)); + const setAndStore = (next: any) => { + const value = typeof next === 'function' ? next(store.get(id)) : next; + store.set(id, value); + setState(value); + }; + return [state, setAndStore] as any; + }); + + const Component = () => { + const { tab, setTab } = useA11yContext(); + return ( + + ); + }; + + const { getByTestId, unmount } = render( + + + + ); + + expect(getByTestId('setPasses').textContent).toBe('violations'); + act(() => getByTestId('setPasses').click()); + expect(getByTestId('setPasses').textContent).toBe('passes'); + + unmount(); + const second = render( + + + + ); + + expect(second.getByTestId('setPasses').textContent).toBe('passes'); + }); + + it('should prioritize a11ySelection over persisted UI state on initial mount', () => { + // Pre-populate persisted UI state to a different value + const store = new Map([[UI_STATE_ID, { highlighted: false, tab: RuleType.PASS }]]); + + mockedApi.useAddonState.mockImplementation((id: string, defaultState: any) => { + if (!store.has(id)) { + store.set(id, defaultState); + } + const [state, setState] = React.useState(store.get(id)); + const setAndStore = (next: any) => { + const value = typeof next === 'function' ? next(store.get(id)) : next; + store.set(id, value); + setState(value); + }; + return [state, setAndStore] as any; + }); + + // Simulate deep link selection + const getQueryParam = vi.fn(); + getQueryParam.mockReturnValue('violations.color-contrast.2'); + const setQueryParams = vi.fn(); + mockedApi.useStorybookApi.mockReturnValue({ + getCurrentStoryData, + getParameters, + getQueryParam, + setQueryParams, + } as any); + + const Component = () => { + const { highlighted, tab } = useA11yContext(); + return ( + <> +
{String(highlighted)}
+
{String(tab)}
+ + ); + }; + + const { getByTestId } = render( + + + + ); + + expect(getByTestId('hl').textContent).toBe('true'); + expect(getByTestId('tab').textContent).toBe('violations'); + expect(setQueryParams).toHaveBeenCalledWith({ a11ySelection: '' }); + }); }); diff --git a/code/addons/a11y/src/components/A11yContext.tsx b/code/addons/a11y/src/components/A11yContext.tsx index 5f926aed988d..ffc193c0d94f 100644 --- a/code/addons/a11y/src/components/A11yContext.tsx +++ b/code/addons/a11y/src/components/A11yContext.tsx @@ -25,7 +25,13 @@ import type { Report } from 'storybook/preview-api'; import { convert, themes } from 'storybook/theming'; import { getFriendlySummaryForAxeResult, getTitleForAxeResult } from '../axeRuleMappingHelper'; -import { ADDON_ID, EVENTS, STATUS_TYPE_ID_A11Y, STATUS_TYPE_ID_COMPONENT_TEST } from '../constants'; +import { + ADDON_ID, + EVENTS, + STATUS_TYPE_ID_A11Y, + STATUS_TYPE_ID_COMPONENT_TEST, + UI_STATE_ID, +} from '../constants'; import type { A11yParameters } from '../params'; import type { A11YReport, EnhancedResult, EnhancedResults } from '../types'; import { RuleType } from '../types'; @@ -107,15 +113,24 @@ export const A11yContextProvider: FC = (props) => { }, [api]); const [results, setResults] = useAddonState(ADDON_ID); - const [tab, setTab] = useState(() => { + const [uiState, setUiState] = useAddonState<{ highlighted: boolean; tab: RuleType }>( + UI_STATE_ID, + { + highlighted: false, + tab: RuleType.VIOLATION, + } + ); + const [tab, setTabState] = useState(() => { const [type] = a11ySelection?.split('.') ?? []; return type && Object.values(RuleType).includes(type as RuleType) ? (type as RuleType) - : RuleType.VIOLATION; + : uiState.tab; }); const [error, setError] = useState(undefined); const [status, setStatus] = useState(getInitialStatus(manual)); - const [highlighted, setHighlighted] = useState(!!a11ySelection); + const [highlighted, setHighlighted] = useState(() => + a11ySelection ? true : uiState.highlighted + ); const { storyId } = useStorybookState(); const currentStoryA11yStatusValue = experimental_useStatusStore( @@ -135,9 +150,19 @@ export const A11yContextProvider: FC = (props) => { return unsubscribe; }, [storyId]); - const handleToggleHighlight = useCallback( - () => setHighlighted((prevHighlighted) => !prevHighlighted), - [] + const handleToggleHighlight = useCallback(() => { + setHighlighted((prevHighlighted) => { + const next = !prevHighlighted; + setUiState((prev) => ({ ...prev, highlighted: next })); + return next; + }); + }, [setUiState]); + const setTab = useCallback( + (type: RuleType) => { + setTabState(type); + setUiState((prev) => ({ ...prev, tab: type })); + }, + [setUiState] ); const [selectedItems, setSelectedItems] = useState>(() => { @@ -297,6 +322,21 @@ export const A11yContextProvider: FC = (props) => { const isInitial = status === 'initial'; + // If a deep link is provided, prefer it once on mount and persist UI state accordingly + useEffect(() => { + if (!a11ySelection) { + return; + } + setHighlighted(true); + const [type] = a11ySelection.split('.') ?? []; + if (type && Object.values(RuleType).includes(type as RuleType)) { + setTab(type as RuleType); + } + setUiState((prev) => ({ ...prev, highlighted: true })); + // We intentionally do not include setHighlighted/setTab/setUiState in deps to avoid loops + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [a11ySelection]); + useEffect(() => { emit(REMOVE_HIGHLIGHT, `${ADDON_ID}/selected`); emit(REMOVE_HIGHLIGHT, `${ADDON_ID}/others`); diff --git a/code/addons/a11y/src/constants.ts b/code/addons/a11y/src/constants.ts index f9293f9e260b..8bbef9b4f643 100755 --- a/code/addons/a11y/src/constants.ts +++ b/code/addons/a11y/src/constants.ts @@ -1,6 +1,7 @@ export const ADDON_ID = 'storybook/a11y'; export const PANEL_ID = `${ADDON_ID}/panel`; export const PARAM_KEY = `a11y`; +export const UI_STATE_ID = `${ADDON_ID}/ui`; const RESULT = `${ADDON_ID}/result`; const REQUEST = `${ADDON_ID}/request`; const RUNNING = `${ADDON_ID}/running`; From 4ef6304458270b5c2d4995cbfad6037e7fe45f11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Oct 2025 10:42:15 +0000 Subject: [PATCH 04/16] Fix: Don't add triple slash reference to vitest.config files Only add `/// ` to vite.config files, not vitest.config files, because vitest.config files already have the vitest/config types available by default. Co-authored-by: yannbf <1671563+yannbf@users.noreply.github.com> --- code/addons/vitest/src/postinstall.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/code/addons/vitest/src/postinstall.ts b/code/addons/vitest/src/postinstall.ts index 82bff176da2c..6bea9c1a0322 100644 --- a/code/addons/vitest/src/postinstall.ts +++ b/code/addons/vitest/src/postinstall.ts @@ -468,11 +468,14 @@ export default async function postInstall(options: PostinstallOptions) { logger.plain(` ${rootConfig}`); const formattedContent = await formatFileContent(rootConfig, generate(target).code); + // Only add triple slash reference to vite.config files, not vitest.config files + // vitest.config files already have the vitest/config types available + const shouldAddReference = !configFileHasTypeReference && !vitestConfigFile; await writeFile( rootConfig, - configFileHasTypeReference - ? formattedContent - : '/// \n' + formattedContent + shouldAddReference + ? '/// \n' + formattedContent + : formattedContent ); } else { logErrors( From 7ad957f2dba9e57ef5a6c6be7ab9c4e2a61b8126 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Oct 2025 10:26:04 +0000 Subject: [PATCH 05/16] Fix vitest addon to extract coverage config to top-level test object When transforming vitest configs with coverage settings, the addon now correctly: - Extracts the coverage property from the existing test config - Keeps it at the top-level test object (where it's global) - Moves other test properties to workspace/projects array items - Adds test cases to verify the fix works for both workspace and projects modes Co-authored-by: yannbf <1671563+yannbf@users.noreply.github.com> --- .../vitest/src/updateVitestFile.test.ts | 188 ++++++++++++++++++ code/addons/vitest/src/updateVitestFile.ts | 26 ++- 2 files changed, 213 insertions(+), 1 deletion(-) diff --git a/code/addons/vitest/src/updateVitestFile.test.ts b/code/addons/vitest/src/updateVitestFile.test.ts index a244b23f02ee..04bd87cd375d 100644 --- a/code/addons/vitest/src/updateVitestFile.test.ts +++ b/code/addons/vitest/src/updateVitestFile.test.ts @@ -771,6 +771,194 @@ describe('updateConfigFile', () => { }));" `); }); + + it('extracts coverage config and keeps it at top level when using workspace', async () => { + const source = babel.babelParse( + await loadTemplate('vitest.config.template.ts', { + CONFIG_DIR: '.storybook', + BROWSER_CONFIG: "{ provider: 'playwright' }", + SETUP_FILE: '../.storybook/vitest.setup.ts', + }) + ); + const target = babel.babelParse(` + import { mergeConfig, defineConfig } from 'vitest/config' + import viteConfig from './vite.config' + + export default mergeConfig( + viteConfig, + defineConfig({ + test: { + name: 'node', + environment: 'happy-dom', + include: ['**/*.test.ts'], + coverage: { + exclude: [ + 'storybook.setup.ts', + '**/*.stories.*', + ], + }, + }, + }) + ) + `); + + const before = babel.generate(target).code; + const updated = updateConfigFile(source, target); + expect(updated).toBe(true); + + const after = babel.generate(target).code; + + // check if the code was updated at all + expect(after).not.toBe(before); + + // check if the code was updated correctly + // Coverage should stay at the top level, not moved into the workspace + expect(getDiff(before, after)).toMatchInlineSnapshot(` + " import { mergeConfig, defineConfig } from 'vitest/config'; + import viteConfig from './vite.config'; + + + import path from 'node:path'; + + import { fileURLToPath } from 'node:url'; + + import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; + + const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); + + + + // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon + + + export default mergeConfig(viteConfig, defineConfig({ + test: { + + - name: 'node', + - environment: 'happy-dom', + - include: ['**/*.test.ts'], + - + coverage: { + exclude: ['storybook.setup.ts', '**/*.stories.*'] + + - } + - + + }, + + workspace: [{ + + extends: true, + + test: { + + name: 'node', + + environment: 'happy-dom', + + include: ['**/*.test.ts'] + + } + + }, { + + extends: true, + + plugins: [ + + // The plugin will run tests for the stories defined in your Storybook config + + // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest + + storybookTest({ + + configDir: path.join(dirname, '.storybook') + + })], + + test: { + + name: 'storybook', + + browser: { + + provider: 'playwright' + + }, + + setupFiles: ['../.storybook/vitest.setup.ts'] + + } + + }] + + + } + }));" + `); + }); + + it('extracts coverage config and keeps it at top level when using projects', async () => { + const source = babel.babelParse( + await loadTemplate('vitest.config.3.2.template.ts', { + CONFIG_DIR: '.storybook', + BROWSER_CONFIG: "{ provider: 'playwright' }", + SETUP_FILE: '../.storybook/vitest.setup.ts', + }) + ); + const target = babel.babelParse(` + import { mergeConfig, defineConfig } from 'vitest/config' + import viteConfig from './vite.config' + + export default mergeConfig( + viteConfig, + defineConfig({ + test: { + name: 'node', + environment: 'happy-dom', + include: ['**/*.test.ts'], + coverage: { + exclude: [ + 'storybook.setup.ts', + '**/*.stories.*', + ], + }, + }, + }) + ) + `); + + const before = babel.generate(target).code; + const updated = updateConfigFile(source, target); + expect(updated).toBe(true); + + const after = babel.generate(target).code; + + // check if the code was updated at all + expect(after).not.toBe(before); + + // check if the code was updated correctly + // Coverage should stay at the top level, not moved into the projects + expect(getDiff(before, after)).toMatchInlineSnapshot(` + " import { mergeConfig, defineConfig } from 'vitest/config'; + import viteConfig from './vite.config'; + + + import path from 'node:path'; + + import { fileURLToPath } from 'node:url'; + + import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; + + const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); + + + + // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon + + + export default mergeConfig(viteConfig, defineConfig({ + test: { + + - name: 'node', + - environment: 'happy-dom', + - include: ['**/*.test.ts'], + - + coverage: { + exclude: ['storybook.setup.ts', '**/*.stories.*'] + + - } + - + + }, + + projects: [{ + + extends: true, + + test: { + + name: 'node', + + environment: 'happy-dom', + + include: ['**/*.test.ts'] + + } + + }, { + + extends: true, + + plugins: [ + + // The plugin will run tests for the stories defined in your Storybook config + + // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest + + storybookTest({ + + configDir: path.join(dirname, '.storybook') + + })], + + test: { + + name: 'storybook', + + browser: { + + provider: 'playwright' + + }, + + setupFiles: ['../.storybook/vitest.setup.ts'] + + } + + }] + + + } + }));" + `); + }); }); describe('updateWorkspaceFile', () => { diff --git a/code/addons/vitest/src/updateVitestFile.ts b/code/addons/vitest/src/updateVitestFile.ts index 04af8916ac28..1dd2cd1a404b 100644 --- a/code/addons/vitest/src/updateVitestFile.ts +++ b/code/addons/vitest/src/updateVitestFile.ts @@ -249,6 +249,24 @@ export const updateConfigFile = (source: BabelFile['ast'], target: BabelFile['as workspaceOrProjectsProp && workspaceOrProjectsProp.value.type === 'ArrayExpression' ) { + // Extract coverage config before creating the test project + const coverageProp = existingTestProp.value.properties.find( + (p) => + p.type === 'ObjectProperty' && + p.key.type === 'Identifier' && + p.key.name === 'coverage' + ) as t.ObjectProperty | undefined; + + // Create a new test config without the coverage property + const testPropsWithoutCoverage = existingTestProp.value.properties.filter( + (p) => p !== coverageProp + ); + + const testConfigForProject: t.ObjectExpression = { + type: 'ObjectExpression', + properties: testPropsWithoutCoverage, + }; + // Create the existing test project const existingTestProject: t.ObjectExpression = { type: 'ObjectExpression', @@ -263,7 +281,7 @@ export const updateConfigFile = (source: BabelFile['ast'], target: BabelFile['as { type: 'ObjectProperty', key: { type: 'Identifier', name: 'test' }, - value: existingTestProp.value, + value: testConfigForProject, computed: false, shorthand: false, }, @@ -278,6 +296,12 @@ export const updateConfigFile = (source: BabelFile['ast'], target: BabelFile['as (p) => p !== existingTestProp ); + // If there was a coverage config, add it to the template's test config (at the top level of the test object) + // Insert it at the beginning so it appears before workspace/projects + if (coverageProp && templateTestProp.value.type === 'ObjectExpression') { + templateTestProp.value.properties.unshift(coverageProp); + } + // Merge the template properties (which now include our existing test project in the array) mergeProperties(properties, defineConfigProps.properties); } else { From b1be7096a4d313e9243b86615928dc66298df6fd Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Mon, 27 Oct 2025 11:52:57 +0100 Subject: [PATCH 06/16] Core: Enhance warning for Testing Library's `screen` usage in docs mode --- code/core/src/test/testing-library.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/code/core/src/test/testing-library.ts b/code/core/src/test/testing-library.ts index 3c9fb5a6d1d2..2414a8e418c4 100644 --- a/code/core/src/test/testing-library.ts +++ b/code/core/src/test/testing-library.ts @@ -25,10 +25,18 @@ const testingLibrary = instrument( testingLibrary.screen = new Proxy(testingLibrary.screen, { get(target, prop, receiver) { - once.warn(dedent` - You are using Testing Library's \`screen\` object. Use \`within(canvasElement)\` instead. - More info: https://storybook.js.org/docs/writing-tests/interaction-testing?ref=error - `); + if (typeof window !== 'undefined') { + const isInDocsViewMode = + globalThis.location?.href?.includes('viewMode=docs') || + globalThis.parent?.location?.href?.includes('/docs/'); + if (isInDocsViewMode) { + once.warn(dedent` + You are using Testing Library's \`screen\` object while the story is rendered in docs mode. This will likely lead to issues, as multiple stories are rendered in the same page and therefore screen will potentially find multiple elements. Use the \`canvas\` utility from the story context instead, which will scope the queries to each story's canvas. + + More info: https://storybook.js.org/docs/writing-tests/interaction-testing?ref=error#querying-the-canvas + `); + } + } return Reflect.get(target, prop, receiver); }, }); From 6afa95b4dd770a0b0dfcfa6b0d0c0983d9f862f3 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Mon, 27 Oct 2025 12:34:46 +0100 Subject: [PATCH 07/16] refactor --- .../a11y/src/components/A11yContext.test.tsx | 137 -------------- .../a11y/src/components/A11yContext.tsx | 171 ++++++++---------- code/addons/a11y/src/manager.tsx | 13 +- code/addons/a11y/src/types.ts | 9 + 4 files changed, 98 insertions(+), 232 deletions(-) diff --git a/code/addons/a11y/src/components/A11yContext.test.tsx b/code/addons/a11y/src/components/A11yContext.test.tsx index 729e0d409dda..4d72f31e2955 100644 --- a/code/addons/a11y/src/components/A11yContext.test.tsx +++ b/code/addons/a11y/src/components/A11yContext.test.tsx @@ -334,141 +334,4 @@ describe('A11yContext', () => { expect(emit).toHaveBeenCalledWith(EVENTS.MANUAL, storyId, expect.any(Object)); }); - - it('should persist highlighted state across provider remounts', () => { - const store = new Map(); - mockedApi.useAddonState.mockImplementation((id: string, defaultState: any) => { - if (!store.has(id)) { - store.set(id, defaultState); - } - const [state, setState] = React.useState(store.get(id)); - const setAndStore = (next: any) => { - const value = typeof next === 'function' ? next(store.get(id)) : next; - store.set(id, value); - setState(value); - }; - return [state, setAndStore] as any; - }); - - const Component = () => { - const { highlighted, toggleHighlight } = useA11yContext(); - return ( - - ); - }; - - const { getByTestId, unmount } = render( - - - - ); - - expect(getByTestId('toggle').textContent).toBe('false'); - act(() => getByTestId('toggle').click()); - expect(getByTestId('toggle').textContent).toBe('true'); - - unmount(); - const second = render( - - - - ); - - expect(second.getByTestId('toggle').textContent).toBe('true'); - }); - - it('should persist selected tab across provider remounts', () => { - const store = new Map(); - mockedApi.useAddonState.mockImplementation((id: string, defaultState: any) => { - if (!store.has(id)) { - store.set(id, defaultState); - } - const [state, setState] = React.useState(store.get(id)); - const setAndStore = (next: any) => { - const value = typeof next === 'function' ? next(store.get(id)) : next; - store.set(id, value); - setState(value); - }; - return [state, setAndStore] as any; - }); - - const Component = () => { - const { tab, setTab } = useA11yContext(); - return ( - - ); - }; - - const { getByTestId, unmount } = render( - - - - ); - - expect(getByTestId('setPasses').textContent).toBe('violations'); - act(() => getByTestId('setPasses').click()); - expect(getByTestId('setPasses').textContent).toBe('passes'); - - unmount(); - const second = render( - - - - ); - - expect(second.getByTestId('setPasses').textContent).toBe('passes'); - }); - - it('should prioritize a11ySelection over persisted UI state on initial mount', () => { - // Pre-populate persisted UI state to a different value - const store = new Map([[UI_STATE_ID, { highlighted: false, tab: RuleType.PASS }]]); - - mockedApi.useAddonState.mockImplementation((id: string, defaultState: any) => { - if (!store.has(id)) { - store.set(id, defaultState); - } - const [state, setState] = React.useState(store.get(id)); - const setAndStore = (next: any) => { - const value = typeof next === 'function' ? next(store.get(id)) : next; - store.set(id, value); - setState(value); - }; - return [state, setAndStore] as any; - }); - - // Simulate deep link selection - const getQueryParam = vi.fn(); - getQueryParam.mockReturnValue('violations.color-contrast.2'); - const setQueryParams = vi.fn(); - mockedApi.useStorybookApi.mockReturnValue({ - getCurrentStoryData, - getParameters, - getQueryParam, - setQueryParams, - } as any); - - const Component = () => { - const { highlighted, tab } = useA11yContext(); - return ( - <> -
{String(highlighted)}
-
{String(tab)}
- - ); - }; - - const { getByTestId } = render( - - - - ); - - expect(getByTestId('hl').textContent).toBe('true'); - expect(getByTestId('tab').textContent).toBe('violations'); - expect(setQueryParams).toHaveBeenCalledWith({ a11ySelection: '' }); - }); }); diff --git a/code/addons/a11y/src/components/A11yContext.tsx b/code/addons/a11y/src/components/A11yContext.tsx index ffc193c0d94f..8e4c38b0f0a9 100644 --- a/code/addons/a11y/src/components/A11yContext.tsx +++ b/code/addons/a11y/src/components/A11yContext.tsx @@ -25,15 +25,9 @@ import type { Report } from 'storybook/preview-api'; import { convert, themes } from 'storybook/theming'; import { getFriendlySummaryForAxeResult, getTitleForAxeResult } from '../axeRuleMappingHelper'; -import { - ADDON_ID, - EVENTS, - STATUS_TYPE_ID_A11Y, - STATUS_TYPE_ID_COMPONENT_TEST, - UI_STATE_ID, -} from '../constants'; +import { ADDON_ID, EVENTS, STATUS_TYPE_ID_A11Y, STATUS_TYPE_ID_COMPONENT_TEST } from '../constants'; import type { A11yParameters } from '../params'; -import type { A11YReport, EnhancedResult, EnhancedResults } from '../types'; +import type { A11YReport, EnhancedResult, EnhancedResults, Status } from '../types'; import { RuleType } from '../types'; import type { TestDiscrepancy } from './TestDiscrepancyMessage'; @@ -92,8 +86,6 @@ export const A11yContext = createContext({ handleSelectionChange: () => {}, }); -type Status = 'initial' | 'manual' | 'running' | 'error' | 'component-test-error' | 'ran' | 'ready'; - export const A11yContextProvider: FC = (props) => { const parameters = useParameter('a11y', {}); @@ -112,25 +104,20 @@ export const A11yContextProvider: FC = (props) => { return value; }, [api]); - const [results, setResults] = useAddonState(ADDON_ID); - const [uiState, setUiState] = useAddonState<{ highlighted: boolean; tab: RuleType }>( - UI_STATE_ID, - { + const [{ results, ui, error, status }, setState] = useAddonState<{ + ui: { highlighted: boolean; tab: RuleType }; + results: EnhancedResults | undefined; + error: unknown; + status: Status; + }>(ADDON_ID, { + ui: { highlighted: false, tab: RuleType.VIOLATION, - } - ); - const [tab, setTabState] = useState(() => { - const [type] = a11ySelection?.split('.') ?? []; - return type && Object.values(RuleType).includes(type as RuleType) - ? (type as RuleType) - : uiState.tab; + }, + results: undefined, + error: undefined, + status: getInitialStatus(manual), }); - const [error, setError] = useState(undefined); - const [status, setStatus] = useState(getInitialStatus(manual)); - const [highlighted, setHighlighted] = useState(() => - a11ySelection ? true : uiState.highlighted - ); const { storyId } = useStorybookState(); const currentStoryA11yStatusValue = experimental_useStatusStore( @@ -143,27 +130,16 @@ export const A11yContextProvider: FC = (props) => { const current = statuses[storyId]?.[STATUS_TYPE_ID_COMPONENT_TEST]; const previous = previousStatuses[storyId]?.[STATUS_TYPE_ID_COMPONENT_TEST]; if (current?.value === 'status-value:error' && previous?.value !== 'status-value:error') { - setStatus('component-test-error'); + setState((prev) => ({ ...prev, status: 'component-test-error' })); } } ); return unsubscribe; - }, [storyId]); + }, [setState, storyId]); const handleToggleHighlight = useCallback(() => { - setHighlighted((prevHighlighted) => { - const next = !prevHighlighted; - setUiState((prev) => ({ ...prev, highlighted: next })); - return next; - }); - }, [setUiState]); - const setTab = useCallback( - (type: RuleType) => { - setTabState(type); - setUiState((prev) => ({ ...prev, tab: type })); - }, - [setUiState] - ); + setState((prev) => ({ ...prev, ui: { ...prev.ui, highlighted: !prev.ui.highlighted } })); + }, [setState]); const [selectedItems, setSelectedItems] = useState>(() => { const initialValue = new Map(); @@ -178,9 +154,9 @@ export const A11yContextProvider: FC = (props) => { // All items are expanded if something is selected from each result for the current tab const allExpanded = useMemo(() => { - const currentResults = results?.[tab]; - return currentResults?.every((result) => selectedItems.has(`${tab}.${result.id}`)) ?? false; - }, [results, selectedItems, tab]); + const currentResults = results?.[ui.tab]; + return currentResults?.every((result) => selectedItems.has(`${ui.tab}.${result.id}`)) ?? false; + }, [results, selectedItems, ui.tab]); const toggleOpen = useCallback( (event: React.SyntheticEvent, type: RuleType, item: EnhancedResult) => { @@ -199,33 +175,34 @@ export const A11yContextProvider: FC = (props) => { setSelectedItems( (prev) => new Map( - results?.[tab]?.map((result) => { - const key = `${tab}.${result.id}`; + results?.[ui.tab]?.map((result) => { + const key = `${ui.tab}.${result.id}`; return [key, prev.get(key) ?? `${key}.1`]; }) ?? [] ) ); - }, [results, tab]); + }, [results, ui.tab]); const handleSelectionChange = useCallback((key: string) => { const [type, id] = key.split('.'); setSelectedItems((prev) => new Map(prev.set(`${type}.${id}`, key))); }, []); - const handleError = useCallback((err: unknown) => { - setStatus('error'); - setError(err); - }, []); + const handleError = useCallback( + (err: unknown) => { + setState((prev) => ({ ...prev, status: 'error', error: err })); + }, + [setState] + ); const handleResult = useCallback( (axeResults: EnhancedResults, id: string) => { if (storyId === id) { - setStatus('ran'); - setResults(axeResults); + setState((prev) => ({ ...prev, status: 'ran', results: axeResults })); setTimeout(() => { if (status === 'ran') { - setStatus('ready'); + setState((prev) => ({ ...prev, status: 'ready' })); } if (selectedItems.size === 1) { const [key] = selectedItems.values(); @@ -234,7 +211,7 @@ export const A11yContextProvider: FC = (props) => { }, 900); } }, - [setResults, status, storyId, selectedItems] + [storyId, setState, status, selectedItems] ); const handleSelect = useCallback( @@ -275,14 +252,16 @@ export const A11yContextProvider: FC = (props) => { const handleReset = useCallback( ({ newPhase }: { newPhase: string }) => { if (newPhase === 'loading') { - setResults(undefined); - setStatus(manual ? 'manual' : 'initial'); - } - if (newPhase === 'afterEach' && !manual) { - setStatus('running'); + setState((prev) => ({ + ...prev, + results: undefined, + status: manual ? 'manual' : 'initial', + })); + } else if (newPhase === 'afterEach' && !manual) { + setState((prev) => ({ ...prev, status: 'running' })); } }, - [manual, setResults] + [manual, setState] ); const emit = useChannel( @@ -294,7 +273,7 @@ export const A11yContextProvider: FC = (props) => { [STORY_RENDER_PHASE_CHANGED]: handleReset, [STORY_FINISHED]: handleReport, [STORY_HOT_UPDATED]: () => { - setStatus('running'); + setState((prev) => ({ ...prev, status: 'running' })); emit(EVENTS.MANUAL, storyId, parameters); }, }, @@ -302,9 +281,9 @@ export const A11yContextProvider: FC = (props) => { ); const handleManual = useCallback(() => { - setStatus('running'); + setState((prev) => ({ ...prev, status: 'running' })); emit(EVENTS.MANUAL, storyId, parameters); - }, [emit, parameters, storyId]); + }, [emit, parameters, setState, storyId]); const handleCopyLink = useCallback(async (linkPath: string) => { const { createCopyToClipboardFunction } = await import('storybook/internal/components'); @@ -317,8 +296,8 @@ export const A11yContextProvider: FC = (props) => { ); useEffect(() => { - setStatus(getInitialStatus(manual)); - }, [getInitialStatus, manual]); + setState((prev) => ({ ...prev, status: getInitialStatus(manual) })); + }, [getInitialStatus, manual, setState]); const isInitial = status === 'initial'; @@ -327,12 +306,16 @@ export const A11yContextProvider: FC = (props) => { if (!a11ySelection) { return; } - setHighlighted(true); - const [type] = a11ySelection.split('.') ?? []; - if (type && Object.values(RuleType).includes(type as RuleType)) { - setTab(type as RuleType); - } - setUiState((prev) => ({ ...prev, highlighted: true })); + setState((prev) => { + const update = { ...prev.ui, highlighted: true }; + + const [type] = a11ySelection.split('.') ?? []; + if (type && Object.values(RuleType).includes(type as RuleType)) { + update.tab = type as RuleType; + } + return { ...prev, ui: update }; + }); + // We intentionally do not include setHighlighted/setTab/setUiState in deps to avoid loops // eslint-disable-next-line react-hooks/exhaustive-deps }, [a11ySelection]); @@ -341,13 +324,13 @@ export const A11yContextProvider: FC = (props) => { emit(REMOVE_HIGHLIGHT, `${ADDON_ID}/selected`); emit(REMOVE_HIGHLIGHT, `${ADDON_ID}/others`); - if (!highlighted || isInitial) { + if (!ui.highlighted || isInitial) { return; } const selected = Array.from(selectedItems.values()).flatMap((key) => { const [type, id, number] = key.split('.'); - if (type !== tab) { + if (type !== ui.tab) { return []; } const result = results?.[type as RuleType]?.find((r) => r.id === id); @@ -360,7 +343,7 @@ export const A11yContextProvider: FC = (props) => { priority: 1, selectors: selected, styles: { - outline: `1px solid color-mix(in srgb, ${colorsByType[tab]}, transparent 30%)`, + outline: `1px solid color-mix(in srgb, ${colorsByType[ui.tab]}, transparent 30%)`, backgroundColor: 'transparent', }, hoverStyles: { @@ -369,20 +352,20 @@ export const A11yContextProvider: FC = (props) => { focusStyles: { backgroundColor: 'transparent', }, - menu: results?.[tab as RuleType].map((result) => { + menu: results?.[ui.tab as RuleType].map((result) => { const selectors = result.nodes .flatMap((n) => n.target) .map(String) .filter((e) => selected.includes(e)); return [ { - id: `${tab}.${result.id}:info`, + id: `${ui.tab}.${result.id}:info`, title: getTitleForAxeResult(result), description: getFriendlySummaryForAxeResult(result), selectors, }, { - id: `${tab}.${result.id}`, + id: `${ui.tab}.${result.id}`, iconLeft: 'info', iconRight: 'shareAlt', title: 'Learn how to resolve this violation', @@ -394,7 +377,7 @@ export const A11yContextProvider: FC = (props) => { }); } - const others = results?.[tab as RuleType] + const others = results?.[ui.tab as RuleType] .flatMap((r) => r.nodes.flatMap((n) => n.target).map(String)) .filter((e) => ![...unhighlightedSelectors, ...selected].includes(e)); if (others?.length) { @@ -402,8 +385,8 @@ export const A11yContextProvider: FC = (props) => { id: `${ADDON_ID}/others`, selectors: others, styles: { - outline: `1px solid color-mix(in srgb, ${colorsByType[tab]}, transparent 30%)`, - backgroundColor: `color-mix(in srgb, ${colorsByType[tab]}, transparent 60%)`, + outline: `1px solid color-mix(in srgb, ${colorsByType[ui.tab]}, transparent 30%)`, + backgroundColor: `color-mix(in srgb, ${colorsByType[ui.tab]}, transparent 60%)`, }, hoverStyles: { outlineWidth: '2px', @@ -411,20 +394,20 @@ export const A11yContextProvider: FC = (props) => { focusStyles: { backgroundColor: 'transparent', }, - menu: results?.[tab as RuleType].map((result) => { + menu: results?.[ui.tab as RuleType].map((result) => { const selectors = result.nodes .flatMap((n) => n.target) .map(String) .filter((e) => !selected.includes(e)); return [ { - id: `${tab}.${result.id}:info`, + id: `${ui.tab}.${result.id}:info`, title: getTitleForAxeResult(result), description: getFriendlySummaryForAxeResult(result), selectors, }, { - id: `${tab}.${result.id}`, + id: `${ui.tab}.${result.id}`, iconLeft: 'info', iconRight: 'shareAlt', title: 'Learn how to resolve this violation', @@ -435,7 +418,7 @@ export const A11yContextProvider: FC = (props) => { }), }); } - }, [isInitial, emit, highlighted, results, tab, selectedItems]); + }, [isInitial, emit, ui.highlighted, results, ui.tab, selectedItems]); const discrepancy: TestDiscrepancy = useMemo(() => { if (!currentStoryA11yStatusValue) { @@ -462,14 +445,20 @@ export const A11yContextProvider: FC = (props) => { value={{ parameters, results, - highlighted, + highlighted: ui.highlighted, toggleHighlight: handleToggleHighlight, - tab, - setTab, + tab: ui.tab, + setTab: useCallback( + (type: RuleType) => setState((prev) => ({ ...prev, ui: { ...prev.ui, tab: type } })), + [setState] + ), handleCopyLink, - status, - setStatus, - error, + status: status, + setStatus: useCallback( + (status: Status) => setState((prev) => ({ ...prev, status })), + [setState] + ), + error: error, handleManual, discrepancy, selectedItems, diff --git a/code/addons/a11y/src/manager.tsx b/code/addons/a11y/src/manager.tsx index f854d1eba9ee..95cf080cbe37 100644 --- a/code/addons/a11y/src/manager.tsx +++ b/code/addons/a11y/src/manager.tsx @@ -8,14 +8,19 @@ import { A11YPanel } from './components/A11YPanel'; import { A11yContextProvider } from './components/A11yContext'; import { VisionSimulator } from './components/VisionSimulator'; import { ADDON_ID, PANEL_ID, PARAM_KEY } from './constants'; -import type { EnhancedResults } from './types'; +import type { EnhancedResults, RuleType, Status } from './types'; const Title = () => { const api = useStorybookApi(); const selectedPanel = api.getSelectedPanel(); - const [addonState] = useAddonState(ADDON_ID); - const violationsNb = addonState?.violations?.length || 0; - const incompleteNb = addonState?.incomplete?.length || 0; + const [{ results }] = useAddonState<{ + ui: { highlighted: boolean; tab: RuleType }; + results: EnhancedResults | undefined; + error: unknown; + status: Status; + }>(ADDON_ID); + const violationsNb = results?.violations?.length || 0; + const incompleteNb = results?.incomplete?.length || 0; const count = violationsNb + incompleteNb; const suffix = diff --git a/code/addons/a11y/src/types.ts b/code/addons/a11y/src/types.ts index de2472006a03..61034005d9f3 100644 --- a/code/addons/a11y/src/types.ts +++ b/code/addons/a11y/src/types.ts @@ -56,3 +56,12 @@ export interface A11yTypes { parameters: A11yParameters; globals: A11yGlobals; } + +export type Status = + | 'initial' + | 'manual' + | 'running' + | 'error' + | 'component-test-error' + | 'ran' + | 'ready'; From 53b41dbc1b11230aa3c03fabf102a759d497b89a Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Mon, 27 Oct 2025 13:13:49 +0100 Subject: [PATCH 08/16] improve defaults --- code/addons/a11y/src/components/A11yContext.tsx | 4 +++- code/addons/a11y/src/manager.tsx | 13 +++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/code/addons/a11y/src/components/A11yContext.tsx b/code/addons/a11y/src/components/A11yContext.tsx index 8e4c38b0f0a9..16fc16248522 100644 --- a/code/addons/a11y/src/components/A11yContext.tsx +++ b/code/addons/a11y/src/components/A11yContext.tsx @@ -104,7 +104,7 @@ export const A11yContextProvider: FC = (props) => { return value; }, [api]); - const [{ results, ui, error, status }, setState] = useAddonState<{ + const [state, setState] = useAddonState<{ ui: { highlighted: boolean; tab: RuleType }; results: EnhancedResults | undefined; error: unknown; @@ -119,6 +119,8 @@ export const A11yContextProvider: FC = (props) => { status: getInitialStatus(manual), }); + const { ui, results, error, status } = state; + const { storyId } = useStorybookState(); const currentStoryA11yStatusValue = experimental_useStatusStore( (allStatuses) => allStatuses[storyId]?.[STATUS_TYPE_ID_A11Y]?.value diff --git a/code/addons/a11y/src/manager.tsx b/code/addons/a11y/src/manager.tsx index 95cf080cbe37..3e051a1f5bcf 100644 --- a/code/addons/a11y/src/manager.tsx +++ b/code/addons/a11y/src/manager.tsx @@ -8,7 +8,8 @@ import { A11YPanel } from './components/A11YPanel'; import { A11yContextProvider } from './components/A11yContext'; import { VisionSimulator } from './components/VisionSimulator'; import { ADDON_ID, PANEL_ID, PARAM_KEY } from './constants'; -import type { EnhancedResults, RuleType, Status } from './types'; +import type { EnhancedResults, Status } from './types'; +import { RuleType } from './types'; const Title = () => { const api = useStorybookApi(); @@ -18,7 +19,15 @@ const Title = () => { results: EnhancedResults | undefined; error: unknown; status: Status; - }>(ADDON_ID); + }>(ADDON_ID, { + ui: { + highlighted: false, + tab: RuleType.VIOLATION, + }, + results: undefined, + error: undefined, + status: 'initial', + }); const violationsNb = results?.violations?.length || 0; const incompleteNb = results?.incomplete?.length || 0; const count = violationsNb + incompleteNb; From 4147d30f00d6ff1145762ad37114cde0a94bc899 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Mon, 27 Oct 2025 13:16:00 +0100 Subject: [PATCH 09/16] fix test --- code/addons/a11y/src/manager.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/code/addons/a11y/src/manager.test.tsx b/code/addons/a11y/src/manager.test.tsx index 9652b67652ea..6c31bf623a7a 100644 --- a/code/addons/a11y/src/manager.test.tsx +++ b/code/addons/a11y/src/manager.test.tsx @@ -38,7 +38,7 @@ describe('A11yManager', () => { it('should compute title with no issues', () => { // given - mockedApi.useAddonState.mockImplementation(() => [undefined]); + mockedApi.useAddonState.mockImplementation(() => [{ results: undefined }]); registrationImpl(api as unknown as api.API); const title = mockedAddons.add.mock.calls.map(([_, def]) => def).find(isPanel) ?.title as () => void; @@ -65,8 +65,10 @@ describe('A11yManager', () => { // given mockedApi.useAddonState.mockImplementation(() => [ { - violations: [{}], - incomplete: [{}, {}], + results: { + violations: [{}], + incomplete: [{}, {}], + }, }, ]); registrationImpl(mockedApi); From 676611ab30ce924d17a6c81a362e2aa083627c5c Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Mon, 27 Oct 2025 13:29:22 +0100 Subject: [PATCH 10/16] Refactor A11yContextProvider to streamline state updates and improve selected items handling --- .../a11y/src/components/A11yContext.tsx | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/code/addons/a11y/src/components/A11yContext.tsx b/code/addons/a11y/src/components/A11yContext.tsx index 16fc16248522..24ae79613f8c 100644 --- a/code/addons/a11y/src/components/A11yContext.tsx +++ b/code/addons/a11y/src/components/A11yContext.tsx @@ -203,17 +203,23 @@ export const A11yContextProvider: FC = (props) => { setState((prev) => ({ ...prev, status: 'ran', results: axeResults })); setTimeout(() => { - if (status === 'ran') { - setState((prev) => ({ ...prev, status: 'ready' })); - } - if (selectedItems.size === 1) { - const [key] = selectedItems.values(); - document.getElementById(key)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } + setState((prev) => { + if (prev.status === 'ran') { + return { ...prev, status: 'ready' }; + } + return prev; + }); + setSelectedItems((prev) => { + if (prev.size === 1) { + const [key] = prev.values(); + document.getElementById(key)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + return prev; + }); }, 900); } }, - [storyId, setState, status, selectedItems] + [storyId, setState, setSelectedItems] ); const handleSelect = useCallback( From 9e5690bc95cca87617f6c214b2e907c6004889a6 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Mon, 27 Oct 2025 13:30:33 +0100 Subject: [PATCH 11/16] Update A11yContextProvider to use nullish coalescing for violation counts --- code/addons/a11y/src/manager.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/code/addons/a11y/src/manager.tsx b/code/addons/a11y/src/manager.tsx index 3e051a1f5bcf..32225dcaaf16 100644 --- a/code/addons/a11y/src/manager.tsx +++ b/code/addons/a11y/src/manager.tsx @@ -28,8 +28,8 @@ const Title = () => { error: undefined, status: 'initial', }); - const violationsNb = results?.violations?.length || 0; - const incompleteNb = results?.incomplete?.length || 0; + const violationsNb = results?.violations?.length ?? 0; + const incompleteNb = results?.incomplete?.length ?? 0; const count = violationsNb + incompleteNb; const suffix = From 83f36d8f8ebb45c30e307ee55fbb38b3588d5bb1 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Mon, 27 Oct 2025 13:31:56 +0100 Subject: [PATCH 12/16] Update A11yContextProvider comment to clarify dependency exclusion in useEffect --- code/addons/a11y/src/components/A11yContext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/addons/a11y/src/components/A11yContext.tsx b/code/addons/a11y/src/components/A11yContext.tsx index 24ae79613f8c..48d43fa92225 100644 --- a/code/addons/a11y/src/components/A11yContext.tsx +++ b/code/addons/a11y/src/components/A11yContext.tsx @@ -324,7 +324,7 @@ export const A11yContextProvider: FC = (props) => { return { ...prev, ui: update }; }); - // We intentionally do not include setHighlighted/setTab/setUiState in deps to avoid loops + // We intentionally do not include setState in deps to avoid loops // eslint-disable-next-line react-hooks/exhaustive-deps }, [a11ySelection]); From 0384713710fd1914c57d29bbd49a23fa5395ee99 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Mon, 27 Oct 2025 13:32:06 +0100 Subject: [PATCH 13/16] support non defineConfig calls in mergeConfig --- .../vitest/src/updateVitestFile.test.ts | 96 ++++++++++++++++++- code/addons/vitest/src/updateVitestFile.ts | 64 ++++++------- 2 files changed, 124 insertions(+), 36 deletions(-) diff --git a/code/addons/vitest/src/updateVitestFile.test.ts b/code/addons/vitest/src/updateVitestFile.test.ts index 04bd87cd375d..6e08aa611eef 100644 --- a/code/addons/vitest/src/updateVitestFile.test.ts +++ b/code/addons/vitest/src/updateVitestFile.test.ts @@ -611,6 +611,88 @@ describe('updateConfigFile', () => { }));" `); }); + it('supports mergeConfig without defineConfig calls', async () => { + const source = babel.babelParse( + await loadTemplate('vitest.config.template.ts', { + CONFIG_DIR: '.storybook', + BROWSER_CONFIG: "{ provider: 'playwright' }", + SETUP_FILE: '../.storybook/vitest.setup.ts', + }) + ); + const target = babel.babelParse(` + import { mergeConfig } from 'vite' + import viteConfig from './vite.config' + + export default mergeConfig( + viteConfig, + { + plugins: [react()], + test: { + environment: 'jsdom', + } + } + ) + `); + + const before = babel.generate(target).code; + const updated = updateConfigFile(source, target); + expect(updated).toBe(true); + + const after = babel.generate(target).code; + + // check if the code was updated at all + expect(after).not.toBe(before); + + // check if the code was updated correctly + expect(getDiff(before, after)).toMatchInlineSnapshot(` + " import { mergeConfig } from 'vite'; + import viteConfig from './vite.config'; + + + import path from 'node:path'; + + import { fileURLToPath } from 'node:url'; + + import { defineConfig } from 'vitest/config'; + + import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; + + const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); + + + + // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon + + + export default mergeConfig(viteConfig, { + plugins: [react()], + test: { + + - environment: 'jsdom' + - + + workspace: [{ + + extends: true, + + test: { + + environment: 'jsdom' + + } + + }, { + + extends: true, + + plugins: [ + + // The plugin will run tests for the stories defined in your Storybook config + + // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest + + storybookTest({ + + configDir: path.join(dirname, '.storybook') + + })], + + test: { + + name: 'storybook', + + browser: { + + enabled: true, + + headless: true, + + provider: 'playwright', + + instances: [{ + + browser: 'chromium' + + }] + + }, + + setupFiles: ['../.storybook/vitest.setup.ts'] + + } + + }] + + + } + });" + `); + }); it('supports mergeConfig without config containing test property', async () => { const source = babel.babelParse( @@ -855,7 +937,12 @@ describe('updateConfigFile', () => { + test: { + name: 'storybook', + browser: { - + provider: 'playwright' + + enabled: true, + + headless: true, + + provider: 'playwright', + + instances: [{ + + browser: 'chromium' + + }] + }, + setupFiles: ['../.storybook/vitest.setup.ts'] + } @@ -949,7 +1036,12 @@ describe('updateConfigFile', () => { + test: { + name: 'storybook', + browser: { - + provider: 'playwright' + + enabled: true, + + headless: true, + + provider: 'playwright', + + instances: [{ + + browser: 'chromium' + + }] + }, + setupFiles: ['../.storybook/vitest.setup.ts'] + } diff --git a/code/addons/vitest/src/updateVitestFile.ts b/code/addons/vitest/src/updateVitestFile.ts index 1dd2cd1a404b..0f544dde3482 100644 --- a/code/addons/vitest/src/updateVitestFile.ts +++ b/code/addons/vitest/src/updateVitestFile.ts @@ -123,14 +123,7 @@ export const updateConfigFile = (source: BabelFile['ast'], target: BabelFile['as targetExportDefault.declaration.callee.name === 'mergeConfig' && targetExportDefault.declaration.arguments.length >= 2 ) { - const defineConfigNodes = targetExportDefault.declaration.arguments.filter( - (arg): arg is t.CallExpression => - arg?.type === 'CallExpression' && - arg.callee.type === 'Identifier' && - arg.callee.name === 'defineConfig' && - arg.arguments[0]?.type === 'ObjectExpression' - ); - canHandleConfig = defineConfigNodes.length > 0; + canHandleConfig = true; } if (!canHandleConfig) { @@ -194,37 +187,40 @@ export const updateConfigFile = (source: BabelFile['ast'], target: BabelFile['as exportDefault.declaration.callee.name === 'mergeConfig' && exportDefault.declaration.arguments.length >= 2 ) { - const defineConfigNodes = exportDefault.declaration.arguments.filter( - (arg): arg is t.CallExpression => + // We first collect all the potential config object nodes from mergeConfig, these can be: + // - defineConfig({ ... }) calls + // - plain object expressions { ... } without a defineConfig helper + const configObjectNodes: t.ObjectExpression[] = []; + + for (const arg of exportDefault.declaration.arguments) { + if ( arg?.type === 'CallExpression' && arg.callee.type === 'Identifier' && arg.callee.name === 'defineConfig' && arg.arguments[0]?.type === 'ObjectExpression' - ); + ) { + configObjectNodes.push(arg.arguments[0] as t.ObjectExpression); + } else if (arg?.type === 'ObjectExpression') { + configObjectNodes.push(arg); + } + } - const defineConfigNodeWithTest = defineConfigNodes.find( - (node) => - node.arguments[0].type === 'ObjectExpression' && - node.arguments[0].properties.some( - (p) => - p.type === 'ObjectProperty' && - p.key.type === 'Identifier' && - p.key.name === 'test' - ) + // Prefer a config object that already contains a `test` property + const configObjectWithTest = configObjectNodes.find((obj) => + obj.properties.some( + (p) => + p.type === 'ObjectProperty' && p.key.type === 'Identifier' && p.key.name === 'test' + ) ); - // Give precedence for the defineConfig expression which contains a test config property - // As with mergeConfig you never know where the test could be e.g. mergeConfig(viteConfig, defineConfig({}), defineConfig({ test: {...} })) - const defineConfigNode = defineConfigNodeWithTest || defineConfigNodes[0]; + const targetConfigObject = configObjectWithTest || configObjectNodes[0]; - if (!defineConfigNode) { + if (!targetConfigObject) { return false; } - const defineConfigProps = defineConfigNode.arguments[0] as t.ObjectExpression; - - // Check if there's already a test property in the defineConfig - const existingTestProp = defineConfigProps.properties.find( + // Check if there's already a test property in the target config + const existingTestProp = targetConfigObject.properties.find( (p) => p.type === 'ObjectProperty' && p.key.type === 'Identifier' && p.key.name === 'test' ) as t.ObjectProperty | undefined; @@ -291,8 +287,8 @@ export const updateConfigFile = (source: BabelFile['ast'], target: BabelFile['as // Add the existing test project to the template's array workspaceOrProjectsProp.value.elements.unshift(existingTestProject); - // Remove the existing test property from defineConfig since we're moving it to the array - defineConfigProps.properties = defineConfigProps.properties.filter( + // Remove the existing test property from the target config since we're moving it to the array + targetConfigObject.properties = targetConfigObject.properties.filter( (p) => p !== existingTestProp ); @@ -303,18 +299,18 @@ export const updateConfigFile = (source: BabelFile['ast'], target: BabelFile['as } // Merge the template properties (which now include our existing test project in the array) - mergeProperties(properties, defineConfigProps.properties); + mergeProperties(properties, targetConfigObject.properties); } else { // Fallback to original behavior if template structure is unexpected - mergeProperties(properties, defineConfigProps.properties); + mergeProperties(properties, targetConfigObject.properties); } } else { // Fallback to original behavior if template doesn't have expected structure - mergeProperties(properties, defineConfigProps.properties); + mergeProperties(properties, targetConfigObject.properties); } } else { // No existing test config, just merge normally - mergeProperties(properties, defineConfigProps.properties); + mergeProperties(properties, targetConfigObject.properties); } updated = true; } From a66d81711662817839ae6949ac9ccf9a28ab02f3 Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Mon, 27 Oct 2025 13:37:13 +0100 Subject: [PATCH 14/16] improvements --- code/core/src/test/testing-library.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/code/core/src/test/testing-library.ts b/code/core/src/test/testing-library.ts index 2414a8e418c4..d3874eb6f867 100644 --- a/code/core/src/test/testing-library.ts +++ b/code/core/src/test/testing-library.ts @@ -26,15 +26,21 @@ const testingLibrary = instrument( testingLibrary.screen = new Proxy(testingLibrary.screen, { get(target, prop, receiver) { if (typeof window !== 'undefined') { - const isInDocsViewMode = - globalThis.location?.href?.includes('viewMode=docs') || - globalThis.parent?.location?.href?.includes('/docs/'); - if (isInDocsViewMode) { - once.warn(dedent` + let isInDocsViewMode = false; + + try { + isInDocsViewMode = + globalThis.location?.href?.includes('viewMode=docs') || + globalThis.parent?.location?.href?.includes('/docs/'); + if (isInDocsViewMode) { + once.warn(dedent` You are using Testing Library's \`screen\` object while the story is rendered in docs mode. This will likely lead to issues, as multiple stories are rendered in the same page and therefore screen will potentially find multiple elements. Use the \`canvas\` utility from the story context instead, which will scope the queries to each story's canvas. More info: https://storybook.js.org/docs/writing-tests/interaction-testing?ref=error#querying-the-canvas `); + } + } catch { + // Ignore cross-origin errors when accessing parent.location } } return Reflect.get(target, prop, receiver); From f40907a4398851b7df817f69a033e9343f8e8bef Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Mon, 27 Oct 2025 14:59:09 +0100 Subject: [PATCH 15/16] fix url checking --- code/core/src/test/testing-library.ts | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/code/core/src/test/testing-library.ts b/code/core/src/test/testing-library.ts index d3874eb6f867..b0a3b44c44df 100644 --- a/code/core/src/test/testing-library.ts +++ b/code/core/src/test/testing-library.ts @@ -25,23 +25,12 @@ const testingLibrary = instrument( testingLibrary.screen = new Proxy(testingLibrary.screen, { get(target, prop, receiver) { - if (typeof window !== 'undefined') { - let isInDocsViewMode = false; + if (typeof window !== 'undefined' && globalThis.location?.href?.includes('viewMode=docs')) { + once.warn(dedent` + You are using Testing Library's \`screen\` object while the story is rendered in docs mode. This will likely lead to issues, as multiple stories are rendered in the same page and therefore screen will potentially find multiple elements. Use the \`canvas\` utility from the story context instead, which will scope the queries to each story's canvas. - try { - isInDocsViewMode = - globalThis.location?.href?.includes('viewMode=docs') || - globalThis.parent?.location?.href?.includes('/docs/'); - if (isInDocsViewMode) { - once.warn(dedent` - You are using Testing Library's \`screen\` object while the story is rendered in docs mode. This will likely lead to issues, as multiple stories are rendered in the same page and therefore screen will potentially find multiple elements. Use the \`canvas\` utility from the story context instead, which will scope the queries to each story's canvas. - - More info: https://storybook.js.org/docs/writing-tests/interaction-testing?ref=error#querying-the-canvas - `); - } - } catch { - // Ignore cross-origin errors when accessing parent.location - } + More info: https://storybook.js.org/docs/writing-tests/interaction-testing?ref=error#querying-the-canvas + `); } return Reflect.get(target, prop, receiver); }, From f034eb9765875c5cd3490da26e8633f3255c64cd Mon Sep 17 00:00:00 2001 From: storybook-bot <32066757+storybook-bot@users.noreply.github.com> Date: Mon, 27 Oct 2025 14:05:12 +0000 Subject: [PATCH 16/16] Write changelog for 10.0.0-rc.3 [skip ci] --- CHANGELOG.prerelease.md | 7 +++++++ code/package.json | 3 ++- docs/versions/next.json | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index c54934ed4126..7bf43a02efe1 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -1,3 +1,10 @@ +## 10.0.0-rc.3 + +- A11y: Persist tab/highlight across docs navigation - [#32762](https://github.com/storybookjs/storybook/pull/32762), thanks @404Dealer! +- Addon Vitest: Fix incorrect file modifications during setup - [#32844](https://github.com/storybookjs/storybook/pull/32844), thanks @yannbf! +- Core: Enhance warning for Testing Library's `screen` usage in docs mode - [#32851](https://github.com/storybookjs/storybook/pull/32851), thanks @yannbf! +- Core: Mark pnp support as deprecated - [#32645](https://github.com/storybookjs/storybook/pull/32645), thanks @ndelangen! + ## 10.0.0-rc.2 - CLI: Fix Nextjs project creation in empty directories - [#32828](https://github.com/storybookjs/storybook/pull/32828), thanks @yannbf! diff --git a/code/package.json b/code/package.json index 914ced98efe1..bf26af1bf85f 100644 --- a/code/package.json +++ b/code/package.json @@ -283,5 +283,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "10.0.0-rc.3" } diff --git a/docs/versions/next.json b/docs/versions/next.json index 0b6e09de90dc..6c9f63a10996 100644 --- a/docs/versions/next.json +++ b/docs/versions/next.json @@ -1 +1 @@ -{"version":"10.0.0-rc.2","info":{"plain":"- CLI: Fix Nextjs project creation in empty directories - [#32828](https://github.com/storybookjs/storybook/pull/32828), thanks @yannbf!\n- CLI: Switch over to modern-tar - [#32763](https://github.com/storybookjs/storybook/pull/32763), thanks @ayuhito!\n- Core: Add parameter typings for addon-pseudo-state - [#32384](https://github.com/storybookjs/storybook/pull/32384), thanks @mrginglymus!\n- Core: Dedupe aria-query and @testing-library/dom packages - [#32801](https://github.com/storybookjs/storybook/pull/32801), thanks @mrginglymus!\n- Core: Improve es-toolkit usage for better tree-shaking - [#32787](https://github.com/storybookjs/storybook/pull/32787), thanks @mrginglymus!\n- Core: Replace es-toolkit compat imports with non-compat - [#32837](https://github.com/storybookjs/storybook/pull/32837), thanks @mrginglymus!\n- React: Simplify version detection - [#32802](https://github.com/storybookjs/storybook/pull/32802), thanks @mrginglymus!\n- Vite: Optimize @storybook/addon-docs/blocks dependency - [#32798](https://github.com/storybookjs/storybook/pull/32798), thanks @mrginglymus!"}} \ No newline at end of file +{"version":"10.0.0-rc.3","info":{"plain":"- A11y: Persist tab/highlight across docs navigation - [#32762](https://github.com/storybookjs/storybook/pull/32762), thanks @404Dealer!\n- Addon Vitest: Fix incorrect file modifications during setup - [#32844](https://github.com/storybookjs/storybook/pull/32844), thanks @yannbf!\n- Core: Enhance warning for Testing Library's `screen` usage in docs mode - [#32851](https://github.com/storybookjs/storybook/pull/32851), thanks @yannbf!\n- Core: Mark pnp support as deprecated - [#32645](https://github.com/storybookjs/storybook/pull/32645), thanks @ndelangen!"}} \ No newline at end of file