From 304fd3f2ea5b262c4af77736f80376a2ac97ba05 Mon Sep 17 00:00:00 2001 From: Maks Pikov Date: Wed, 18 Mar 2026 22:40:25 +0000 Subject: [PATCH 01/23] fix(addon-a11y): clear status transition timer on unmount to prevent test flake --- .../addons/a11y/src/components/A11yContext.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/code/addons/a11y/src/components/A11yContext.tsx b/code/addons/a11y/src/components/A11yContext.tsx index 90c6f2f3f328..b3e65ad8eac4 100644 --- a/code/addons/a11y/src/components/A11yContext.tsx +++ b/code/addons/a11y/src/components/A11yContext.tsx @@ -1,5 +1,5 @@ import type { FC, PropsWithChildren } from 'react'; -import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { STORY_CHANGED, @@ -143,6 +143,16 @@ export const A11yContextProvider: FC = (props) => { setState((prev) => ({ ...prev, ui: { ...prev.ui, highlighted: !prev.ui.highlighted } })); }, [setState]); + const statusTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (statusTimerRef.current !== null) { + clearTimeout(statusTimerRef.current); + } + }; + }, []); + const [selectedItems, setSelectedItems] = useState>(() => { const initialValue = new Map(); // Check if the a11ySelection param is a valid format before parsing it @@ -202,7 +212,11 @@ export const A11yContextProvider: FC = (props) => { if (storyId === id) { setState((prev) => ({ ...prev, status: 'ran', results: axeResults })); - setTimeout(() => { + if (statusTimerRef.current !== null) { + clearTimeout(statusTimerRef.current); + } + statusTimerRef.current = setTimeout(() => { + statusTimerRef.current = null; setState((prev) => { if (prev.status === 'ran') { return { ...prev, status: 'ready' }; From f5567b44abbc285d19168d88408f2d962b5b20e0 Mon Sep 17 00:00:00 2001 From: Nathan Jessen Date: Fri, 20 Mar 2026 12:35:52 -0500 Subject: [PATCH 02/23] Automigrations: fix nextjs-to-nextjs-vite corrupting configs already using @storybook/nextjs-vite The regex /@storybook\/nextjs/g matches as a substring inside @storybook/nextjs-vite, rewriting it to @storybook/nextjs-vite-vite. Projects that had both @storybook/nextjs and @storybook/nextjs-vite installed simultaneously (valid in SB9) would hit this because their main.ts already referenced @storybook/nextjs-vite. Fix: add a negative lookahead so only bare @storybook/nextjs is replaced. --- .../fixes/nextjs-to-nextjs-vite.test.ts | 31 +++++++++++++++++++ .../fixes/nextjs-to-nextjs-vite.ts | 5 +-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/code/lib/cli-storybook/src/automigrate/fixes/nextjs-to-nextjs-vite.test.ts b/code/lib/cli-storybook/src/automigrate/fixes/nextjs-to-nextjs-vite.test.ts index f09e8ad5e96e..7aa94adcce74 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/nextjs-to-nextjs-vite.test.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/nextjs-to-nextjs-vite.test.ts @@ -231,6 +231,37 @@ describe('nextjs-to-nextjs-vite', () => { ); }); + it('should not corrupt main config that already references @storybook/nextjs-vite', async () => { + // Regression: projects with both @storybook/nextjs and @storybook/nextjs-vite installed + // (valid in SB9) already use nextjs-vite in main.ts. Without the fix, the regex would + // rewrite @storybook/nextjs-vite to @storybook/nextjs-vite-vite. + const result = { + hasNextjsPackage: true, + packageJsonFiles: [], + }; + + mockReadFile.mockResolvedValue(` + import type { StorybookConfig } from '@storybook/nextjs-vite'; + export default { + framework: { name: '@storybook/nextjs-vite', options: {} }, + }; + `); + + vi.mocked(mockPackageManager.getDependencyVersion).mockReturnValue('7.0.0'); + + await nextjsToNextjsVite.run!({ + result, + dryRun: false, + packageManager: mockPackageManager, + mainConfigPath: '/project/.storybook/main.ts', + storiesPaths: [], + configDir: '.storybook', + storybookVersion: '10.0.0', + } as any); + + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + it('should handle dry run mode', async () => { const result = { hasNextjsPackage: true, diff --git a/code/lib/cli-storybook/src/automigrate/fixes/nextjs-to-nextjs-vite.ts b/code/lib/cli-storybook/src/automigrate/fixes/nextjs-to-nextjs-vite.ts index e96dfc7b31df..7f914eb4e5ab 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/nextjs-to-nextjs-vite.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/nextjs-to-nextjs-vite.ts @@ -21,8 +21,9 @@ const transformMainConfig = async (mainConfigPath: string, dryRun: boolean): Pro return false; } - // Replace @storybook/nextjs with @storybook/nextjs-vite in the content - const transformedContent = content.replace(/@storybook\/nextjs/g, '@storybook/nextjs-vite'); + // Replace @storybook/nextjs with @storybook/nextjs-vite, using a negative lookahead + // to avoid corrupting references that are already @storybook/nextjs-vite + const transformedContent = content.replace(/@storybook\/nextjs(?!-vite)/g, '@storybook/nextjs-vite'); if (transformedContent !== content && !dryRun) { await writeFile(mainConfigPath, transformedContent); From 53ff088a30b9b68b669a295017e2dc7627980cdd Mon Sep 17 00:00:00 2001 From: Maks Pikov Date: Sun, 22 Mar 2026 01:04:44 +0000 Subject: [PATCH 03/23] fix: apply prettier formatting to A11yContext --- .../a11y/src/components/A11yContext.tsx | 362 +++++++++++------- 1 file changed, 224 insertions(+), 138 deletions(-) diff --git a/code/addons/a11y/src/components/A11yContext.tsx b/code/addons/a11y/src/components/A11yContext.tsx index b3e65ad8eac4..a5733cd5bf6e 100644 --- a/code/addons/a11y/src/components/A11yContext.tsx +++ b/code/addons/a11y/src/components/A11yContext.tsx @@ -1,5 +1,13 @@ -import type { FC, PropsWithChildren } from 'react'; -import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import type { FC, PropsWithChildren } from "react"; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { STORY_CHANGED, @@ -7,10 +15,14 @@ import { STORY_HOT_UPDATED, STORY_RENDER_PHASE_CHANGED, type StoryFinishedPayload, -} from 'storybook/internal/core-events'; +} from "storybook/internal/core-events"; -import type { ClickEventDetails, HighlightMenuItem } from 'storybook/highlight'; -import { HIGHLIGHT, REMOVE_HIGHLIGHT, SCROLL_INTO_VIEW } from 'storybook/highlight'; +import type { ClickEventDetails, HighlightMenuItem } from "storybook/highlight"; +import { + HIGHLIGHT, + REMOVE_HIGHLIGHT, + SCROLL_INTO_VIEW, +} from "storybook/highlight"; import { experimental_getStatusStore, experimental_useStatusStore, @@ -20,20 +32,33 @@ import { useParameter, useStorybookApi, useStorybookState, -} from 'storybook/manager-api'; -import type { Report } from 'storybook/preview-api'; -import { convert, themes } from 'storybook/theming'; +} from "storybook/manager-api"; +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 type { A11yParameters } from '../params'; -import type { A11yReport, EnhancedResult, EnhancedResults, Status } from '../types'; -import { RuleType } from '../types'; -import type { TestDiscrepancy } from './TestDiscrepancyMessage'; +import { + getFriendlySummaryForAxeResult, + getTitleForAxeResult, +} from "../axeRuleMappingHelper"; +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, + Status, +} from "../types"; +import { RuleType } from "../types"; +import type { TestDiscrepancy } from "./TestDiscrepancyMessage"; // These elements should not be highlighted because they usually cover the whole page. // They may still appear in the results and be selectable though. -const unhighlightedSelectors = ['html', 'body', 'main']; +const unhighlightedSelectors = ["html", "body", "main"]; export interface A11yContextStore { parameters: A11yParameters; @@ -49,7 +74,11 @@ export interface A11yContextStore { handleManual: () => void; discrepancy: TestDiscrepancy; selectedItems: Map; - toggleOpen: (event: React.SyntheticEvent, type: RuleType, item: EnhancedResult) => void; + toggleOpen: ( + event: React.SyntheticEvent, + type: RuleType, + item: EnhancedResult, + ) => void; allExpanded: boolean; handleCollapseAll: () => void; handleExpandAll: () => void; @@ -73,7 +102,7 @@ export const A11yContext = createContext({ handleCopyLink: () => {}, setTab: () => {}, setStatus: () => {}, - status: 'initial', + status: "initial", error: undefined, handleManual: () => {}, discrepancy: null, @@ -87,19 +116,25 @@ export const A11yContext = createContext({ }); export const A11yContextProvider: FC = (props) => { - const parameters = useParameter('a11y', {}); + const parameters = useParameter("a11y", {}); const [globals] = useGlobals() ?? []; const api = useStorybookApi(); - const getInitialStatus = useCallback((manual = false) => (manual ? 'manual' : 'initial'), []); + const getInitialStatus = useCallback( + (manual = false) => (manual ? "manual" : "initial"), + [], + ); - const manual = useMemo(() => globals?.a11y?.manual ?? false, [globals?.a11y?.manual]); + const manual = useMemo( + () => globals?.a11y?.manual ?? false, + [globals?.a11y?.manual], + ); const a11ySelection = useMemo(() => { - const value = api.getQueryParam('a11ySelection'); + const value = api.getQueryParam("a11ySelection"); if (value) { - api.setQueryParams({ a11ySelection: '' }); + api.setQueryParams({ a11ySelection: "" }); } return value; }, [api]); @@ -123,24 +158,31 @@ export const A11yContextProvider: FC = (props) => { const { storyId } = useStorybookState(); const currentStoryA11yStatusValue = experimental_useStatusStore( - (allStatuses) => allStatuses[storyId]?.[STATUS_TYPE_ID_A11Y]?.value + (allStatuses) => allStatuses[storyId]?.[STATUS_TYPE_ID_A11Y]?.value, ); useEffect(() => { - const unsubscribe = experimental_getStatusStore('storybook/component-test').onAllStatusChange( - (statuses, previousStatuses) => { - 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') { - setState((prev) => ({ ...prev, status: 'component-test-error' })); - } + const unsubscribe = experimental_getStatusStore( + "storybook/component-test", + ).onAllStatusChange((statuses, previousStatuses) => { + 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" + ) { + setState((prev) => ({ ...prev, status: "component-test-error" })); } - ); + }); return unsubscribe; }, [setState, storyId]); const handleToggleHighlight = useCallback(() => { - setState((prev) => ({ ...prev, ui: { ...prev.ui, highlighted: !prev.ui.highlighted } })); + setState((prev) => ({ + ...prev, + ui: { ...prev.ui, highlighted: !prev.ui.highlighted }, + })); }, [setState]); const statusTimerRef = useRef | null>(null); @@ -153,30 +195,42 @@ export const A11yContextProvider: FC = (props) => { }; }, []); - const [selectedItems, setSelectedItems] = useState>(() => { - const initialValue = new Map(); - // Check if the a11ySelection param is a valid format before parsing it - // It should look like `violation.aria-hidden-body.1` - if (a11ySelection && /^[a-z]+.[a-z-]+.[0-9]+$/.test(a11ySelection)) { - const [type, id] = a11ySelection.split('.'); - initialValue.set(`${type}.${id}`, a11ySelection); - } - return initialValue; - }); + const [selectedItems, setSelectedItems] = useState>( + () => { + const initialValue = new Map(); + // Check if the a11ySelection param is a valid format before parsing it + // It should look like `violation.aria-hidden-body.1` + if (a11ySelection && /^[a-z]+.[a-z-]+.[0-9]+$/.test(a11ySelection)) { + const [type, id] = a11ySelection.split("."); + initialValue.set(`${type}.${id}`, a11ySelection); + } + return initialValue; + }, + ); // All items are expanded if something is selected from each result for the current tab const allExpanded = useMemo(() => { const currentResults = results?.[ui.tab]; - return currentResults?.every((result) => selectedItems.has(`${ui.tab}.${result.id}`)) ?? false; + 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) => { + ( + event: React.SyntheticEvent, + type: RuleType, + item: EnhancedResult, + ) => { event.stopPropagation(); const key = `${type}.${item.id}`; - setSelectedItems((prev) => new Map(prev.delete(key) ? prev : prev.set(key, `${key}.1`))); + setSelectedItems( + (prev) => new Map(prev.delete(key) ? prev : prev.set(key, `${key}.1`)), + ); }, - [] + [], ); const handleCollapseAll = useCallback(() => { @@ -190,27 +244,27 @@ export const A11yContextProvider: FC = (props) => { results?.[ui.tab]?.map((result) => { const key = `${ui.tab}.${result.id}`; return [key, prev.get(key) ?? `${key}.1`]; - }) ?? [] - ) + }) ?? [], + ), ); }, [results, ui.tab]); const handleSelectionChange = useCallback((key: string) => { - const [type, id] = key.split('.'); + const [type, id] = key.split("."); setSelectedItems((prev) => new Map(prev.set(`${type}.${id}`, key))); }, []); const handleError = useCallback( (err: unknown) => { - setState((prev) => ({ ...prev, status: 'error', error: err })); + setState((prev) => ({ ...prev, status: "error", error: err })); }, - [setState] + [setState], ); const handleResult = useCallback( (axeResults: EnhancedResults, id: string) => { if (storyId === id) { - setState((prev) => ({ ...prev, status: 'ran', results: axeResults })); + setState((prev) => ({ ...prev, status: "ran", results: axeResults })); if (statusTimerRef.current !== null) { clearTimeout(statusTimerRef.current); @@ -218,72 +272,82 @@ export const A11yContextProvider: FC = (props) => { statusTimerRef.current = setTimeout(() => { statusTimerRef.current = null; setState((prev) => { - if (prev.status === 'ran') { - return { ...prev, status: 'ready' }; + 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' }); + document + .getElementById(key) + ?.scrollIntoView({ behavior: "smooth", block: "center" }); } return prev; }); }, 900); } }, - [storyId, setState, setSelectedItems] + [storyId, setState, setSelectedItems], ); const handleSelect = useCallback( (itemId: string, details: ClickEventDetails) => { - const [type, id] = itemId.split('.'); - const { helpUrl, nodes } = results?.[type as RuleType]?.find((r) => r.id === id) || {}; - const openedWindow = helpUrl && window.open(helpUrl, '_blank', 'noopener,noreferrer'); + const [type, id] = itemId.split("."); + const { helpUrl, nodes } = + results?.[type as RuleType]?.find((r) => r.id === id) || {}; + const openedWindow = + helpUrl && window.open(helpUrl, "_blank", "noopener,noreferrer"); if (nodes && !openedWindow) { const index = - nodes.findIndex((n) => details.selectors.some((s) => s === String(n.target))) ?? -1; + nodes.findIndex((n) => + details.selectors.some((s) => s === String(n.target)), + ) ?? -1; if (index !== -1) { const key = `${type}.${id}.${index + 1}`; setSelectedItems(new Map([[`${type}.${id}`, key]])); setTimeout(() => { - document.getElementById(key)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + document + .getElementById(key) + ?.scrollIntoView({ behavior: "smooth", block: "center" }); }, 100); } } }, - [results] + [results], ); const handleReport = useCallback( ({ reporters }: StoryFinishedPayload) => { - const a11yReport = reporters.find((r) => r.type === 'a11y') as Report | undefined; + const a11yReport = reporters.find((r) => r.type === "a11y") as + | Report + | undefined; if (a11yReport) { - if ('error' in a11yReport.result) { + if ("error" in a11yReport.result) { handleError(a11yReport.result.error); } else { handleResult(a11yReport.result, storyId); } } }, - [handleError, handleResult, storyId] + [handleError, handleResult, storyId], ); const handleReset = useCallback( ({ newPhase }: { newPhase: string }) => { - if (newPhase === 'loading') { + if (newPhase === "loading") { setState((prev) => ({ ...prev, results: undefined, - status: manual ? 'manual' : 'initial', + status: manual ? "manual" : "initial", })); - } else if (newPhase === 'afterEach' && !manual) { - setState((prev) => ({ ...prev, status: 'running' })); + } else if (newPhase === "afterEach" && !manual) { + setState((prev) => ({ ...prev, status: "running" })); } }, - [manual, setState] + [manual, setState], ); const emit = useChannel( @@ -295,33 +359,44 @@ export const A11yContextProvider: FC = (props) => { [STORY_RENDER_PHASE_CHANGED]: handleReset, [STORY_FINISHED]: handleReport, [STORY_HOT_UPDATED]: () => { - setState((prev) => ({ ...prev, status: 'running' })); + setState((prev) => ({ ...prev, status: "running" })); emit(EVENTS.MANUAL, storyId, parameters); }, }, - [handleReset, handleReport, handleSelect, handleError, handleResult, parameters, storyId] + [ + handleReset, + handleReport, + handleSelect, + handleError, + handleResult, + parameters, + storyId, + ], ); const handleManual = useCallback(() => { - setState((prev) => ({ ...prev, status: 'running' })); + setState((prev) => ({ ...prev, status: "running" })); emit(EVENTS.MANUAL, storyId, parameters); }, [emit, parameters, setState, storyId]); const handleCopyLink = useCallback(async (linkPath: string) => { - const { createCopyToClipboardFunction } = await import('storybook/internal/components'); - await createCopyToClipboardFunction()(`${window.location.origin}${linkPath}`); + const { createCopyToClipboardFunction } = + await import("storybook/internal/components"); + await createCopyToClipboardFunction()( + `${window.location.origin}${linkPath}`, + ); }, []); const handleJumpToElement = useCallback( (target: string) => emit(SCROLL_INTO_VIEW, target), - [emit] + [emit], ); useEffect(() => { setState((prev) => ({ ...prev, status: getInitialStatus(manual) })); }, [getInitialStatus, manual, setState]); - const isInitial = status === 'initial'; + const isInitial = status === "initial"; // If a deep link is provided, prefer it once on mount and persist UI state accordingly useEffect(() => { @@ -331,7 +406,7 @@ export const A11yContextProvider: FC = (props) => { setState((prev) => { const update = { ...prev.ui, highlighted: true }; - const [type] = a11ySelection.split('.') ?? []; + const [type] = a11ySelection.split(".") ?? []; if (type && Object.values(RuleType).includes(type as RuleType)) { update.tab = type as RuleType; } @@ -351,7 +426,7 @@ export const A11yContextProvider: FC = (props) => { } const selected = Array.from(selectedItems.values()).flatMap((key) => { - const [type, id, number] = key.split('.'); + const [type, id, number] = key.split("."); if (type !== ui.tab) { return []; } @@ -366,36 +441,38 @@ export const A11yContextProvider: FC = (props) => { selectors: selected, styles: { outline: `1px solid color-mix(in srgb, ${colorsByType[ui.tab]}, transparent 30%)`, - backgroundColor: 'transparent', + backgroundColor: "transparent", }, hoverStyles: { - outlineWidth: '2px', + outlineWidth: "2px", }, focusStyles: { - backgroundColor: 'transparent', + backgroundColor: "transparent", }, - 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: `${ui.tab}.${result.id}:info`, - title: getTitleForAxeResult(result), - description: getFriendlySummaryForAxeResult(result), - selectors, - }, - { - id: `${ui.tab}.${result.id}`, - iconLeft: 'info', - iconRight: 'shareAlt', - title: 'Learn how to resolve this violation', - clickEvent: EVENTS.SELECT, - selectors, - }, - ]; - }), + 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: `${ui.tab}.${result.id}:info`, + title: getTitleForAxeResult(result), + description: getFriendlySummaryForAxeResult(result), + selectors, + }, + { + id: `${ui.tab}.${result.id}`, + iconLeft: "info", + iconRight: "shareAlt", + title: "Learn how to resolve this violation", + clickEvent: EVENTS.SELECT, + selectors, + }, + ]; + }, + ), }); } @@ -411,33 +488,35 @@ export const A11yContextProvider: FC = (props) => { backgroundColor: `color-mix(in srgb, ${colorsByType[ui.tab]}, transparent 60%)`, }, hoverStyles: { - outlineWidth: '2px', + outlineWidth: "2px", }, focusStyles: { - backgroundColor: 'transparent', + backgroundColor: "transparent", }, - 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: `${ui.tab}.${result.id}:info`, - title: getTitleForAxeResult(result), - description: getFriendlySummaryForAxeResult(result), - selectors, - }, - { - id: `${ui.tab}.${result.id}`, - iconLeft: 'info', - iconRight: 'shareAlt', - title: 'Learn how to resolve this violation', - clickEvent: EVENTS.SELECT, - selectors, - }, - ]; - }), + 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: `${ui.tab}.${result.id}:info`, + title: getTitleForAxeResult(result), + description: getFriendlySummaryForAxeResult(result), + selectors, + }, + { + id: `${ui.tab}.${result.id}`, + iconLeft: "info", + iconRight: "shareAlt", + title: "Learn how to resolve this violation", + clickEvent: EVENTS.SELECT, + selectors, + }, + ]; + }, + ), }); } }, [isInitial, emit, ui.highlighted, results, ui.tab, selectedItems]); @@ -446,17 +525,23 @@ export const A11yContextProvider: FC = (props) => { if (!currentStoryA11yStatusValue) { return null; } - if (currentStoryA11yStatusValue === 'status-value:success' && results?.violations.length) { - return 'cliPassedBrowserFailed'; + if ( + currentStoryA11yStatusValue === "status-value:success" && + results?.violations.length + ) { + return "cliPassedBrowserFailed"; } - if (currentStoryA11yStatusValue === 'status-value:error' && !results?.violations.length) { - if (status === 'ready' || status === 'ran') { - return 'browserPassedCliFailed'; + if ( + currentStoryA11yStatusValue === "status-value:error" && + !results?.violations.length + ) { + if (status === "ready" || status === "ran") { + return "browserPassedCliFailed"; } - if (status === 'manual') { - return 'cliFailedButModeManual'; + if (status === "manual") { + return "cliFailedButModeManual"; } } return null; @@ -471,14 +556,15 @@ export const A11yContextProvider: FC = (props) => { toggleHighlight: handleToggleHighlight, tab: ui.tab, setTab: useCallback( - (type: RuleType) => setState((prev) => ({ ...prev, ui: { ...prev.ui, tab: type } })), - [setState] + (type: RuleType) => + setState((prev) => ({ ...prev, ui: { ...prev.ui, tab: type } })), + [setState], ), handleCopyLink, status: status, setStatus: useCallback( (status: Status) => setState((prev) => ({ ...prev, status })), - [setState] + [setState], ), error: error, handleManual, From 262d541736c64360772647f1705475d0f061e240 Mon Sep 17 00:00:00 2001 From: takaaki chida Date: Mon, 23 Mar 2026 22:20:36 +0900 Subject: [PATCH 04/23] Fix builder-vite hash collision causing duplicate variable declarations with pnpm Replace the naive character-code-sum hash with djb2 to prevent collisions when pnpm peer dependency hash suffixes produce paths with identical sums. Closes #34270 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../builder-vite/src/codegen-project-annotations.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/code/builders/builder-vite/src/codegen-project-annotations.ts b/code/builders/builder-vite/src/codegen-project-annotations.ts index 9d13c9390ec9..e2c2388fa389 100644 --- a/code/builders/builder-vite/src/codegen-project-annotations.ts +++ b/code/builders/builder-vite/src/codegen-project-annotations.ts @@ -105,6 +105,11 @@ export function generateProjectAnnotationsCodeFromPreviews(options: { `.trim(); } +/** djb2 hash — http://www.cse.yorku.ca/~oz/hash.html */ function hash(value: string) { - return value.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + let acc = 5381; + for (let i = 0; i < value.length; i++) { + acc = (acc << 5) + acc + value.charCodeAt(i); + } + return acc >>> 0; } From 8863f7886d46a0e032c04d8a220d6df3a6f5eb75 Mon Sep 17 00:00:00 2001 From: takaaki chida Date: Mon, 23 Mar 2026 22:35:56 +0900 Subject: [PATCH 05/23] Apply >>> 0 per iteration to prevent precision loss on long paths Move the unsigned right shift inside the loop so the accumulator stays within 32-bit range on every iteration, avoiding precision loss when hashing long file paths (common with pnpm). Co-Authored-By: Claude Opus 4.6 (1M context) --- code/builders/builder-vite/src/codegen-project-annotations.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/code/builders/builder-vite/src/codegen-project-annotations.ts b/code/builders/builder-vite/src/codegen-project-annotations.ts index e2c2388fa389..1f5b5192145d 100644 --- a/code/builders/builder-vite/src/codegen-project-annotations.ts +++ b/code/builders/builder-vite/src/codegen-project-annotations.ts @@ -109,7 +109,7 @@ export function generateProjectAnnotationsCodeFromPreviews(options: { function hash(value: string) { let acc = 5381; for (let i = 0; i < value.length; i++) { - acc = (acc << 5) + acc + value.charCodeAt(i); + acc = ((acc << 5) + acc + value.charCodeAt(i)) >>> 0; } - return acc >>> 0; + return acc; } From c8e8253aea7a0cbb07c5e1d731fba8161569ea84 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Mon, 23 Mar 2026 16:44:47 +0100 Subject: [PATCH 06/23] Manager: URL-based tag filter state + filter-aware initial story selection --- code/core/src/manager-api/modules/stories.ts | 135 +++++++++++++------ code/core/src/manager-api/modules/tags.ts | 52 +++++++ code/core/src/manager-api/modules/url.ts | 12 ++ code/core/src/manager-api/store.ts | 24 +++- code/core/src/manager-api/tests/tags.test.js | 45 +++++++ code/core/src/manager-api/tests/url.test.js | 32 +++++ code/core/src/router/utils.ts | 4 +- 7 files changed, 256 insertions(+), 48 deletions(-) create mode 100644 code/core/src/manager-api/modules/tags.ts create mode 100644 code/core/src/manager-api/tests/tags.test.js diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index 15803f447faa..a0e10df344c4 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -20,6 +20,8 @@ import { UPDATE_STORY_ARGS, } from 'storybook/internal/core-events'; import { sanitize, toId } from 'storybook/internal/csf'; +import type { NavigateOptions } from 'storybook/internal/router'; +import { queryFromLocation } from 'storybook/internal/router'; import type { API_ComposedRef, API_DocsEntry, @@ -63,6 +65,7 @@ import { import type { ModuleFn } from '../lib/types'; import type { ComposedRef } from '../root'; import { fullStatusStore } from '../stores/status'; +import { parseTagsParam, serializeTagsParam } from './tags'; const { fetch } = global; const STORY_INDEX_PATH = './index.json'; @@ -426,10 +429,31 @@ export const init: ModuleFn = ({ store, navigate, provider, + state: { location }, storyId: initialStoryId, viewMode: initialViewMode, docsOptions = {}, }) => { + const navigateWithQueryParams = (path: string, options?: NavigateOptions) => { + const { customQueryParams } = store.getState(); + const params = Object.entries(customQueryParams) + .filter(([, v]) => v) + .sort(([a], [b]) => (a < b ? -1 : 1)) + .map(([k, v]) => `${k}=${v}`); + const to = [path, ...params].join('&'); + navigate(to, options); + }; + + const persistFilters = (inputPatch: Parameters[0]) => { + return store.setState(inputPatch, { + persistence: 'url' as const, + serialize: (s: ReturnType) => { + const tagsValue = serializeTagsParam(s.includedTagFilters, s.excludedTagFilters); + return { tags: tagsValue || null }; + }, + }); + }; + const api: SubAPI = { storyId: toId, getData: (storyId, refId): any => { @@ -532,10 +556,27 @@ export const init: ModuleFn = ({ } }, selectFirstStory: () => { - const { index } = store.getState(); + const { index, filteredIndex, includedTagFilters, excludedTagFilters } = store.getState(); + const hasActiveFilters = includedTagFilters.length > 0 || excludedTagFilters.length > 0; + + if (hasActiveFilters) { + if (!filteredIndex) { + return; + } + + const firstStory = Object.keys(filteredIndex).find( + (id) => filteredIndex[id].type === 'story' + ); + if (firstStory) { + api.selectStory(firstStory); + } + return; + } + if (!index) { return; } + const firstStory = Object.keys(index).find((id) => index[id].type === 'story'); if (firstStory) { @@ -543,7 +584,7 @@ export const init: ModuleFn = ({ return; } - navigate('/'); + navigateWithQueryParams('/'); }, selectStory: (titleOrId = undefined, name = undefined, options = {}) => { const { ref } = options; @@ -552,7 +593,9 @@ export const init: ModuleFn = ({ const gotoStory = (entry?: API_HashEntry) => { if (entry?.type === 'docs' || entry?.type === 'story') { store.setState({ settings: { ...settings, lastTrackedStoryId: entry.id } }); - navigate(`/${entry.type}/${entry.refId ? `${entry.refId}_${entry.id}` : entry.id}`); + navigateWithQueryParams( + `/${entry.type}/${entry.refId ? `${entry.refId}_${entry.id}` : entry.id}` + ); return true; } return false; @@ -822,25 +865,19 @@ export const init: ModuleFn = ({ provider.channel?.emit(SET_FILTER, { id }); }, + resetTagFilters: async () => { - await store.setState( - (s) => ({ - includedTagFilters: s.defaultIncludedTagFilters, - excludedTagFilters: s.defaultExcludedTagFilters, - }), - { persistence: 'permanent' } - ); + await persistFilters((s) => ({ + includedTagFilters: s.defaultIncludedTagFilters, + excludedTagFilters: s.defaultExcludedTagFilters, + })); + recomputeFilters(); }, setAllTagFilters: async (included: Tag[], excluded: Tag[]) => { - await store.setState( - { - includedTagFilters: included, - excludedTagFilters: excluded, - }, - { persistence: 'permanent' } - ); + await persistFilters({ includedTagFilters: included, excludedTagFilters: excluded }); + recomputeFilters(); }, @@ -857,25 +894,21 @@ export const init: ModuleFn = ({ newExcluded.delete(tag); } } - await store.setState( - { - includedTagFilters: Array.from(newIncluded), - excludedTagFilters: Array.from(newExcluded), - }, - { persistence: 'permanent' } - ); + await persistFilters({ + includedTagFilters: Array.from(newIncluded), + excludedTagFilters: Array.from(newExcluded), + }); + recomputeFilters(); }, removeTagFilters: async (tags: Tag[]) => { const state = store.getState(); - await store.setState( - { - includedTagFilters: state.includedTagFilters.filter((tag) => !tags.includes(tag)), - excludedTagFilters: state.excludedTagFilters.filter((tag) => !tags.includes(tag)), - }, - { persistence: 'permanent' } - ); + await persistFilters({ + includedTagFilters: state.includedTagFilters.filter((tag) => !tags.includes(tag)), + excludedTagFilters: state.excludedTagFilters.filter((tag) => !tags.includes(tag)), + }); + recomputeFilters(); }, }; @@ -924,6 +957,28 @@ export const init: ModuleFn = ({ * - If the user started storybook with a specific page-URL like "/settings/about" */ if (isCanvasRoute) { + const { includedTagFilters, excludedTagFilters, filteredIndex } = state; + const hasActiveFilters = includedTagFilters.length > 0 || excludedTagFilters.length > 0; + + if (hasActiveFilters && !stateHasSelection) { + const storyPassesFilter = filteredIndex && filteredIndex[storyId]?.type === 'story'; + + if (!storyPassesFilter) { + const firstFiltered = filteredIndex + ? Object.keys(filteredIndex).find((id) => { + const entry = filteredIndex[id]; + return entry.type === 'story' || entry.type === 'docs'; + }) + : undefined; + + if (firstFiltered) { + navigateWithQueryParams(`/${viewMode}/${firstFiltered}`); + } + + return; + } + } + if (stateHasSelection && stateSelectionDifferent && isStory) { // The manager state is correct, the preview state is lagging behind provider.channel?.emit(SET_CURRENT_STORY, { @@ -932,7 +987,7 @@ export const init: ModuleFn = ({ }); } else if (stateSelectionDifferent) { // The preview state is correct, the manager state is lagging behind - navigate(`/${viewMode}/${storyId}`); + navigateWithQueryParams(`/${viewMode}/${storyId}`); } } } @@ -1121,17 +1176,11 @@ export const init: ModuleFn = ({ const tagPresets: TagsOptions = global.TAGS_OPTIONS || {}; const defaultTags = getDefaultTagsFromPreset(tagPresets); - // Read persisted tag filter state, supporting migration from the old layout.xxx path - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const persistedState = store.getState() as Record; - const initialIncluded: Tag[] = - persistedState.includedTagFilters ?? - persistedState.layout?.includedTagFilters ?? - defaultTags.included; - const initialExcluded: Tag[] = - persistedState.excludedTagFilters ?? - persistedState.layout?.excludedTagFilters ?? - defaultTags.excluded; + const { tags } = queryFromLocation(location); + const parsedTags = parseTagsParam(tags); + const hasTagsParam = tags !== undefined; + const initialIncluded: Tag[] = hasTagsParam ? parsedTags.included : defaultTags.included; + const initialExcluded: Tag[] = hasTagsParam ? parsedTags.excluded : defaultTags.excluded; // Build initial filters: config sidebar filters first, then our managed filters take priority const initialFilters: Record = { diff --git a/code/core/src/manager-api/modules/tags.ts b/code/core/src/manager-api/modules/tags.ts new file mode 100644 index 000000000000..2f46b3fb7ce9 --- /dev/null +++ b/code/core/src/manager-api/modules/tags.ts @@ -0,0 +1,52 @@ +import type { Tag } from 'storybook/internal/types'; + +export const BUILT_IN_URL_TAG_MAP: Record = { + $changed: '_changed', + $docs: '_docs', + $play: '_play', + $test: '_test', +}; + +export const parseTagsParam = ( + tagsParam: string | undefined +): { included: Tag[]; excluded: Tag[] } => { + if (!tagsParam) { + return { included: [], excluded: [] }; + } + + const included: Tag[] = []; + const excluded: Tag[] = []; + + tagsParam.split(';').forEach((rawTag) => { + if (!rawTag) { + return; + } + + const isExcluded = rawTag.startsWith('!'); + const normalizedTag = isExcluded ? rawTag.slice(1) : rawTag; + const mappedTag = (BUILT_IN_URL_TAG_MAP[normalizedTag] ?? normalizedTag) as Tag; + + if (isExcluded) { + excluded.push(mappedTag); + } else { + included.push(mappedTag); + } + }); + + return { included, excluded }; +}; + +export const serializeTagsParam = (included: Tag[], excluded: Tag[]): string | undefined => { + if (!included.length && !excluded.length) { + return undefined; + } + + const reverseBuiltInUrlTagMap = Object.fromEntries( + Object.entries(BUILT_IN_URL_TAG_MAP).map(([urlTag, internalTag]) => [internalTag, urlTag]) + ) as Record; + + const serializedIncluded = included.map((tag) => reverseBuiltInUrlTagMap[tag] ?? tag); + const serializedExcluded = excluded.map((tag) => `!${reverseBuiltInUrlTagMap[tag] ?? tag}`); + + return [...serializedIncluded, ...serializedExcluded].join(';'); +}; diff --git a/code/core/src/manager-api/modules/url.ts b/code/core/src/manager-api/modules/url.ts index faa74bfb351c..d1f21b0e9377 100644 --- a/code/core/src/manager-api/modules/url.ts +++ b/code/core/src/manager-api/modules/url.ts @@ -130,6 +130,10 @@ export interface QueryParams { [key: string]: string | undefined; } +interface QueryParamInput { + [key: string]: string | undefined | null; +} + /** SubAPI for managing URL navigation and state. */ export interface SubAPI { /** @@ -381,5 +385,13 @@ export const init: ModuleFn = (moduleArgs) => { return { api, state: initialUrlSupport(moduleArgs), + init: () => { + store.registerPersistenceHandler('url', (_patch, serialize) => { + if (serialize) { + const params = serialize(store.getState()); + api.applyQueryParams(params, { replace: true }); + } + }); + }, }; }; diff --git a/code/core/src/manager-api/store.ts b/code/core/src/manager-api/store.ts index b1071e47002b..90becf6bcc0b 100644 --- a/code/core/src/manager-api/store.ts +++ b/code/core/src/manager-api/store.ts @@ -42,8 +42,14 @@ type Patch = Partial; type InputFnPatch = (s: State) => Patch; type InputPatch = Patch | InputFnPatch; +export type PersistenceHandler = ( + patch: Partial, + serialize: ((s: State) => Partial>) | undefined +) => void | Promise; + export interface Options { - persistence: 'none' | 'session' | string; + persistence: 'none' | 'session' | 'url' | string; + serialize?: (s: State) => Partial>; } type CallBack = (s: State) => void; type CallbackOrOptions = CallBack | Options; @@ -54,6 +60,7 @@ export default class Store { upstreamPersistence: boolean; upstreamGetState: GetState; upstreamSetState: SetState; + private persistenceHandlers: Map = new Map(); constructor({ allowPersistence, setState, getState }: Upstream) { this.upstreamPersistence = allowPersistence ?? true; @@ -61,6 +68,10 @@ export default class Store { this.upstreamGetState = getState; } + registerPersistenceHandler(key: string, handler: PersistenceHandler) { + this.persistenceHandlers.set(key, handler); + } + // The assumption is that this will be called once, to initialize the React state // when the module is instantiated getInitialState(base: State) { @@ -115,8 +126,15 @@ export default class Store { }); if (persistence !== 'none' && this.upstreamPersistence) { - const storage = persistence === 'session' ? store.session : store.local; - await update(storage, delta); + if (persistence === 'url') { + const handler = this.persistenceHandlers.get('url'); + if (handler) { + await handler(delta, (options as Options | undefined)?.serialize); + } + } else { + const storage = persistence === 'session' ? store.session : store.local; + await update(storage, delta); + } } if (callback) { diff --git a/code/core/src/manager-api/tests/tags.test.js b/code/core/src/manager-api/tests/tags.test.js new file mode 100644 index 000000000000..94f4cba17bbf --- /dev/null +++ b/code/core/src/manager-api/tests/tags.test.js @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { parseTagsParam, serializeTagsParam } from '../modules/tags'; + +describe('parseTagsParam', () => { + it('returns empty arrays for falsy input', () => { + expect(parseTagsParam(undefined)).toEqual({ included: [], excluded: [] }); + expect(parseTagsParam('')).toEqual({ included: [], excluded: [] }); + }); + + it('parses include/exclude entries and maps known built-in URL tags', () => { + expect( + parseTagsParam( + '$changed;$docs;$play;$test;custom;!blocked;!$changed;!$docs;!$play;!$test;!$unknown' + ) + ).toEqual({ + included: ['_changed', '_docs', '_play', '_test', 'custom'], + excluded: ['blocked', '_changed', '_docs', '_play', '_test', '$unknown'], + }); + }); + + it('ignores empty segments between separators', () => { + expect(parseTagsParam('a;;!b;;;')).toEqual({ + included: ['a'], + excluded: ['b'], + }); + }); +}); + +describe('serializeTagsParam', () => { + it('returns undefined when no tags are provided', () => { + expect(serializeTagsParam([], [])).toBeUndefined(); + }); + + it('serializes include/exclude entries and maps known built-in internal tags', () => { + expect( + serializeTagsParam( + ['_changed', '_docs', '_play', '_test', 'custom', '_unknown'], + ['blocked', '_changed', '_docs', '_play', '_test', '_unknown'] + ) + ).toEqual( + '$changed;$docs;$play;$test;custom;_unknown;!blocked;!$changed;!$docs;!$play;!$test;!_unknown' + ); + }); +}); diff --git a/code/core/src/manager-api/tests/url.test.js b/code/core/src/manager-api/tests/url.test.js index 4b259bdf6599..30485a43c7a6 100644 --- a/code/core/src/manager-api/tests/url.test.js +++ b/code/core/src/manager-api/tests/url.test.js @@ -123,6 +123,38 @@ describe('initial state', () => { }); describe('queryParams', () => { + it('removes a key from customQueryParams when null is passed', () => { + let state = { customQueryParams: { tags: 'a11y', args: 'foo:bar' } }; + const store = { + setState: (change) => { + state = { ...state, ...change }; + }, + getState: () => state, + }; + const channel = new EventEmitter(); + const navigate = vi.fn(); + const { api } = initURL({ + state: { location: { search: '?tags=a11y&args=foo:bar', path: '/', hash: '' } }, + navigate, + store, + provider: { channel }, + }); + + api.applyQueryParams({ tags: null }, { replace: true }); + + // tags key must be absent from stored customQueryParams + expect(state.customQueryParams).not.toHaveProperty('tags'); + expect(state.customQueryParams).toHaveProperty('args', 'foo:bar'); + + // subsequent URL updates must not reintroduce tags + api.applyQueryParams({ args: 'foo:baz' }, { replace: true }); + expect(navigate).toHaveBeenLastCalledWith( + expect.not.stringContaining('tags='), + expect.anything() + ); + expect(state.customQueryParams).not.toHaveProperty('tags'); + }); + it('lets your read out parameters you set previously', () => { let state = {}; const store = { diff --git a/code/core/src/router/utils.ts b/code/core/src/router/utils.ts index a21a2c5eae07..5837b7ca8128 100644 --- a/code/core/src/router/utils.ts +++ b/code/core/src/router/utils.ts @@ -219,8 +219,8 @@ interface Query { const queryFromString = memoize(1000)((s?: string): Query => (s !== undefined ? parse(s) : {})); -export const queryFromLocation = (location: Partial) => { - return queryFromString(location.search ? location.search.slice(1) : ''); +export const queryFromLocation = (location?: Partial) => { + return queryFromString(location?.search ? location.search.slice(1) : ''); }; export const stringifyQuery = (query: Query) => { From 21219f7c735843d866416750fe55d6bdbf0a6541 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Mon, 23 Mar 2026 16:51:16 +0100 Subject: [PATCH 07/23] Clear tags appropriately from internal state --- code/core/src/manager-api/modules/url.ts | 26 +++++++++--------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/code/core/src/manager-api/modules/url.ts b/code/core/src/manager-api/modules/url.ts index d1f21b0e9377..69a9e41514c1 100644 --- a/code/core/src/manager-api/modules/url.ts +++ b/code/core/src/manager-api/modules/url.ts @@ -130,10 +130,6 @@ export interface QueryParams { [key: string]: string | undefined; } -interface QueryParamInput { - [key: string]: string | undefined | null; -} - /** SubAPI for managing URL navigation and state. */ export interface SubAPI { /** @@ -205,7 +201,7 @@ export interface SubAPI { * @param {QueryParams} input - An object containing the query parameters to set. * @returns {void} */ - setQueryParams: (input: QueryParams) => void; + setQueryParams: (input: QueryParam) => void; /** * Set the query parameters for the current URL & navigates. * @@ -213,7 +209,7 @@ export interface SubAPI { * @param {NavigateOptions} options - Options for the navigation. * @returns {void} */ - applyQueryParams: (input: QueryParams, options?: NavigateOptions) => void; + applyQueryParams: (input: QueryParam, options?: NavigateOptions) => void; } export const init: ModuleFn = (moduleArgs) => { @@ -305,16 +301,14 @@ export const init: ModuleFn = (moduleArgs) => { }, setQueryParams(input) { const { customQueryParams } = store.getState(); - const queryParams: QueryParams = {}; - const update = { - ...customQueryParams, - ...Object.entries(input).reduce((acc, [key, value]) => { - if (value !== null) { - acc[key] = value; - } - return acc; - }, queryParams), - }; + const update: QueryParams = { ...customQueryParams }; + for (const [key, value] of Object.entries(input)) { + if (value === null || value === undefined) { + delete update[key]; + } else { + update[key] = value; + } + } if (!deepEqual(customQueryParams, update)) { store.setState({ customQueryParams: update }); provider.channel?.emit(UPDATE_QUERY_PARAMS, update); From e73920dd2f3f5f8535bb410858284fe20669b721 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Mon, 23 Mar 2026 16:52:52 +0100 Subject: [PATCH 08/23] Fix typing --- code/core/src/manager-api/modules/url.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/code/core/src/manager-api/modules/url.ts b/code/core/src/manager-api/modules/url.ts index 69a9e41514c1..a30f36c6d602 100644 --- a/code/core/src/manager-api/modules/url.ts +++ b/code/core/src/manager-api/modules/url.ts @@ -130,6 +130,10 @@ export interface QueryParams { [key: string]: string | undefined; } +interface QueryParamInput { + [key: string]: string | undefined | null; +} + /** SubAPI for managing URL navigation and state. */ export interface SubAPI { /** @@ -201,7 +205,7 @@ export interface SubAPI { * @param {QueryParams} input - An object containing the query parameters to set. * @returns {void} */ - setQueryParams: (input: QueryParam) => void; + setQueryParams: (input: QueryParamInput) => void; /** * Set the query parameters for the current URL & navigates. * @@ -209,7 +213,7 @@ export interface SubAPI { * @param {NavigateOptions} options - Options for the navigation. * @returns {void} */ - applyQueryParams: (input: QueryParam, options?: NavigateOptions) => void; + applyQueryParams: (input: QueryParamInput, options?: NavigateOptions) => void; } export const init: ModuleFn = (moduleArgs) => { From c9da280bd16ebd82672a20d5dd5221441519371d Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 24 Mar 2026 18:33:27 +0100 Subject: [PATCH 09/23] improve handling of null and undefined values in query parameters and serialization --- code/core/src/manager-api/modules/stories.ts | 4 ++-- code/core/src/manager-api/modules/tags.ts | 4 ++-- code/core/src/manager-api/modules/url.ts | 16 ++++++++++------ code/core/src/manager-api/store.ts | 15 ++++++++++++++- code/core/src/manager-api/tests/tags.test.js | 4 ++-- 5 files changed, 30 insertions(+), 13 deletions(-) diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index a0e10df344c4..383a9aba4d4d 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -437,7 +437,7 @@ export const init: ModuleFn = ({ const navigateWithQueryParams = (path: string, options?: NavigateOptions) => { const { customQueryParams } = store.getState(); const params = Object.entries(customQueryParams) - .filter(([, v]) => v) + .filter(([, v]) => v !== null && v !== undefined) .sort(([a], [b]) => (a < b ? -1 : 1)) .map(([k, v]) => `${k}=${v}`); const to = [path, ...params].join('&'); @@ -449,7 +449,7 @@ export const init: ModuleFn = ({ persistence: 'url' as const, serialize: (s: ReturnType) => { const tagsValue = serializeTagsParam(s.includedTagFilters, s.excludedTagFilters); - return { tags: tagsValue || null }; + return { tags: tagsValue ?? null }; }, }); }; diff --git a/code/core/src/manager-api/modules/tags.ts b/code/core/src/manager-api/modules/tags.ts index 2f46b3fb7ce9..0889adc0fba5 100644 --- a/code/core/src/manager-api/modules/tags.ts +++ b/code/core/src/manager-api/modules/tags.ts @@ -36,9 +36,9 @@ export const parseTagsParam = ( return { included, excluded }; }; -export const serializeTagsParam = (included: Tag[], excluded: Tag[]): string | undefined => { +export const serializeTagsParam = (included: Tag[], excluded: Tag[]): string => { if (!included.length && !excluded.length) { - return undefined; + return ''; } const reverseBuiltInUrlTagMap = Object.fromEntries( diff --git a/code/core/src/manager-api/modules/url.ts b/code/core/src/manager-api/modules/url.ts index a30f36c6d602..9f12e606216e 100644 --- a/code/core/src/manager-api/modules/url.ts +++ b/code/core/src/manager-api/modules/url.ts @@ -221,11 +221,11 @@ export const init: ModuleFn = (moduleArgs) => { const navigateTo = ( path: string, - queryParams: Record = {}, + queryParams: Record = {}, options: NavigateOptions = {} ) => { const params = Object.entries(queryParams) - .filter(([, v]) => v) + .filter(([, v]) => v !== null && v !== undefined) .sort(([a], [b]) => (a < b ? -1 : 1)) .map(([k, v]) => `${k}=${v}`); const to = [path, ...params].join('&'); @@ -348,8 +348,8 @@ export const init: ModuleFn = (moduleArgs) => { const { args, initialArgs } = currentStory; const argsString = buildArgsParam(initialArgs, args as Args); - navigateTo(`${path}${hash}`, { ...queryParams, args: argsString }, { replace: true }); - api.setQueryParams({ args: argsString }); + navigateTo(`${path}${hash}`, { ...queryParams, args: argsString || null }, { replace: true }); + api.setQueryParams({ args: argsString || null }); }; provider.channel?.on(SET_CURRENT_STORY, () => updateArgsParam()); @@ -372,8 +372,12 @@ export const init: ModuleFn = (moduleArgs) => { provider.channel?.on(GLOBALS_UPDATED, ({ userGlobals, initialGlobals }: any) => { const { path, hash = '', queryParams } = api.getUrlState(); const globalsString = buildArgsParam(initialGlobals, merge(initialGlobals, userGlobals)); - navigateTo(`${path}${hash}`, { ...queryParams, globals: globalsString }, { replace: true }); - api.setQueryParams({ globals: globalsString }); + navigateTo( + `${path}${hash}`, + { ...queryParams, globals: globalsString || null }, + { replace: true } + ); + api.setQueryParams({ globals: globalsString || null }); }); provider.channel?.on(NAVIGATE_URL, (url: string, options: NavigateOptions) => { diff --git a/code/core/src/manager-api/store.ts b/code/core/src/manager-api/store.ts index 90becf6bcc0b..5e8111926ebf 100644 --- a/code/core/src/manager-api/store.ts +++ b/code/core/src/manager-api/store.ts @@ -78,7 +78,20 @@ export default class Store { // We don't only merge at the very top level (the same way as React setState) // when you set keys, so it makes sense to do the same in combining the two storage modes // Really, you shouldn't store the same key in both places - return { ...base, ...get(store.local), ...get(store.session) }; + const local = get(store.local); + const session = get(store.session); + + // One-time migration: tag filter state moved from localStorage to URL persistence. + // Remove the old keys so they no longer interfere with URL-derived initial state. + for (const storage of [store.local, store.session] as const) { + const persisted = get(storage); + if ('includedTagFilters' in persisted || 'excludedTagFilters' in persisted) { + const { includedTagFilters: _i, excludedTagFilters: _e, ...rest } = persisted; + set(storage, rest); + } + } + + return { ...base, ...local, ...session }; } getState() { diff --git a/code/core/src/manager-api/tests/tags.test.js b/code/core/src/manager-api/tests/tags.test.js index 94f4cba17bbf..2fe89e7ab3da 100644 --- a/code/core/src/manager-api/tests/tags.test.js +++ b/code/core/src/manager-api/tests/tags.test.js @@ -28,8 +28,8 @@ describe('parseTagsParam', () => { }); describe('serializeTagsParam', () => { - it('returns undefined when no tags are provided', () => { - expect(serializeTagsParam([], [])).toBeUndefined(); + it('returns empty string when no tags are provided', () => { + expect(serializeTagsParam([], [])).toBe(''); }); it('serializes include/exclude entries and maps known built-in internal tags', () => { From 0e26cda98586502ec48d538ee8853d483a0d61a1 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 24 Mar 2026 18:54:48 +0100 Subject: [PATCH 10/23] handle optional chaining for location and customQueryParams in navigation --- code/core/src/manager-api/modules/stories.ts | 7 ++-- code/core/src/manager-api/modules/url.ts | 2 +- .../src/manager-api/tests/stories.test.ts | 36 +++++++++---------- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index 383a9aba4d4d..a0212534900d 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -429,14 +429,14 @@ export const init: ModuleFn = ({ store, navigate, provider, - state: { location }, + state: { location } = {} as any, storyId: initialStoryId, viewMode: initialViewMode, docsOptions = {}, }) => { const navigateWithQueryParams = (path: string, options?: NavigateOptions) => { const { customQueryParams } = store.getState(); - const params = Object.entries(customQueryParams) + const params = Object.entries(customQueryParams ?? {}) .filter(([, v]) => v !== null && v !== undefined) .sort(([a], [b]) => (a < b ? -1 : 1)) .map(([k, v]) => `${k}=${v}`); @@ -958,7 +958,8 @@ export const init: ModuleFn = ({ */ if (isCanvasRoute) { const { includedTagFilters, excludedTagFilters, filteredIndex } = state; - const hasActiveFilters = includedTagFilters.length > 0 || excludedTagFilters.length > 0; + const hasActiveFilters = + (includedTagFilters?.length ?? 0) > 0 || (excludedTagFilters?.length ?? 0) > 0; if (hasActiveFilters && !stateHasSelection) { const storyPassesFilter = filteredIndex && filteredIndex[storyId]?.type === 'story'; diff --git a/code/core/src/manager-api/modules/url.ts b/code/core/src/manager-api/modules/url.ts index 9f12e606216e..4c8c6e615648 100644 --- a/code/core/src/manager-api/modules/url.ts +++ b/code/core/src/manager-api/modules/url.ts @@ -296,7 +296,7 @@ export const init: ModuleFn = (moduleArgs) => { const { location, path, customQueryParams, storyId, url, viewMode } = store.getState(); return { path, - hash: location.hash ?? '', + hash: location?.hash ?? '', queryParams: customQueryParams, storyId, url, diff --git a/code/core/src/manager-api/tests/stories.test.ts b/code/core/src/manager-api/tests/stories.test.ts index 3bdbeec046fd..c290f0b1c17f 100644 --- a/code/core/src/manager-api/tests/stories.test.ts +++ b/code/core/src/manager-api/tests/stories.test.ts @@ -713,7 +713,7 @@ describe('stories API', () => { const { navigate, provider } = moduleArgs; provider.channel.emit(STORY_SPECIFIED, { storyId: 'a--1', viewMode: 'story' }); - expect(navigate).toHaveBeenCalledWith('/story/a--1'); + expect(navigate).toHaveBeenCalledWith('/story/a--1', undefined); }); it('DOES not navigate if the story was already selected', async () => { const moduleArgs = createMockModuleArgs({ initialState: { path: '/story/a--1', index: {} } }); @@ -914,7 +914,7 @@ describe('stories API', () => { api.setIndex({ v: 5, entries: navigationEntries }); api.jumpToStory(1); - expect(navigate).toHaveBeenCalledWith('/story/a--2'); + expect(navigate).toHaveBeenCalledWith('/story/a--2', undefined); }); it('works backwards', () => { const initialState = { path: '/story/a--2', storyId: 'a--2', viewMode: 'story' }; @@ -925,7 +925,7 @@ describe('stories API', () => { api.setIndex({ v: 5, entries: navigationEntries }); api.jumpToStory(-1); - expect(navigate).toHaveBeenCalledWith('/story/a--1'); + expect(navigate).toHaveBeenCalledWith('/story/a--1', undefined); }); it('does nothing if you are at the last story and go forward', () => { const initialState = { @@ -1021,7 +1021,7 @@ describe('stories API', () => { api.setIndex({ v: 5, entries: navigationEntries }); api.jumpToComponent(1); - expect(navigate).toHaveBeenCalledWith('/story/b-c--1'); + expect(navigate).toHaveBeenCalledWith('/story/b-c--1', undefined); }); it('works backwards', () => { const initialState = { @@ -1035,7 +1035,7 @@ describe('stories API', () => { api.setIndex({ v: 5, entries: navigationEntries }); api.jumpToComponent(-1); - expect(navigate).toHaveBeenCalledWith('/story/a--1'); + expect(navigate).toHaveBeenCalledWith('/story/a--1', undefined); }); it('does nothing if you are in the last component and go forward', () => { const initialState = { @@ -1071,7 +1071,7 @@ describe('stories API', () => { api.setIndex({ v: 5, entries: navigationEntries }); api.selectStory('a--2'); - expect(navigate).toHaveBeenCalledWith('/story/a--2'); + expect(navigate).toHaveBeenCalledWith('/story/a--2', undefined); }); it('sets view mode to docs if doc-level component is selected', () => { const initialState = { path: '/docs/a--1', storyId: 'a--1', viewMode: 'docs' }; @@ -1094,7 +1094,7 @@ describe('stories API', () => { }, }); api.selectStory('intro'); - expect(navigate).toHaveBeenCalledWith('/docs/intro--docs'); + expect(navigate).toHaveBeenCalledWith('/docs/intro--docs', undefined); }); it('updates lastTrackedStoryId', () => { const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; @@ -1143,7 +1143,7 @@ describe('stories API', () => { // When selecting the component, it should select the first visible child (a--2) api.selectStory('a'); - expect(navigate).toHaveBeenCalledWith('/story/a--2'); + expect(navigate).toHaveBeenCalledWith('/story/a--2', undefined); }); describe('deprecated api', () => { it('allows navigating to a combination of title + name', () => { @@ -1154,7 +1154,7 @@ describe('stories API', () => { api.setIndex({ v: 5, entries: navigationEntries }); api.selectStory('a', '2'); - expect(navigate).toHaveBeenCalledWith('/story/a--2'); + expect(navigate).toHaveBeenCalledWith('/story/a--2', undefined); }); it('allows navigating to a given name (in the current component)', () => { const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; @@ -1164,7 +1164,7 @@ describe('stories API', () => { api.setIndex({ v: 5, entries: navigationEntries }); api.selectStory(undefined, '2'); - expect(navigate).toHaveBeenCalledWith('/story/a--2'); + expect(navigate).toHaveBeenCalledWith('/story/a--2', undefined); }); }); it('allows navigating away from the settings pages', () => { @@ -1175,7 +1175,7 @@ describe('stories API', () => { api.setIndex({ v: 5, entries: navigationEntries }); api.selectStory('a--2'); - expect(navigate).toHaveBeenCalledWith('/story/a--2'); + expect(navigate).toHaveBeenCalledWith('/story/a--2', undefined); }); it('allows navigating to first story in component on call by component id', () => { const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; @@ -1185,7 +1185,7 @@ describe('stories API', () => { api.setIndex({ v: 5, entries: navigationEntries }); api.selectStory('a'); - expect(navigate).toHaveBeenCalledWith('/story/a--1'); + expect(navigate).toHaveBeenCalledWith('/story/a--1', undefined); }); it('allows navigating to first story in group on call by group id', () => { const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; @@ -1195,7 +1195,7 @@ describe('stories API', () => { api.setIndex({ v: 5, entries: navigationEntries }); api.selectStory('b'); - expect(navigate).toHaveBeenCalledWith('/story/b-c--1'); + expect(navigate).toHaveBeenCalledWith('/story/b-c--1', undefined); }); it('allows navigating to first story in component on call by title', () => { const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; @@ -1205,7 +1205,7 @@ describe('stories API', () => { api.setIndex({ v: 5, entries: navigationEntries }); api.selectStory('A'); - expect(navigate).toHaveBeenCalledWith('/story/a--1'); + expect(navigate).toHaveBeenCalledWith('/story/a--1', undefined); }); it('allows navigating to the first story of the current component if passed nothing', () => { const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; @@ -1215,7 +1215,7 @@ describe('stories API', () => { api.setIndex({ v: 5, entries: navigationEntries }); api.selectStory(); - expect(navigate).toHaveBeenCalledWith('/story/a--1'); + expect(navigate).toHaveBeenCalledWith('/story/a--1', undefined); }); describe('component permalinks', () => { it('allows navigating to kind/storyname (legacy api)', () => { @@ -1226,7 +1226,7 @@ describe('stories API', () => { api.setIndex({ v: 5, entries: navigationEntries }); api.selectStory('b/e', '1'); - expect(navigate).toHaveBeenCalledWith('/story/custom-id--1'); + expect(navigate).toHaveBeenCalledWith('/story/custom-id--1', undefined); }); it('allows navigating to component permalink/storyname (legacy api)', () => { const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; @@ -1236,7 +1236,7 @@ describe('stories API', () => { api.setIndex({ v: 5, entries: navigationEntries }); api.selectStory('custom-id', '1'); - expect(navigate).toHaveBeenCalledWith('/story/custom-id--1'); + expect(navigate).toHaveBeenCalledWith('/story/custom-id--1', undefined); }); it('allows navigating to first story in kind on call by kind', () => { const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; @@ -1246,7 +1246,7 @@ describe('stories API', () => { api.setIndex({ v: 5, entries: navigationEntries }); api.selectStory('b/e'); - expect(navigate).toHaveBeenCalledWith('/story/custom-id--1'); + expect(navigate).toHaveBeenCalledWith('/story/custom-id--1', undefined); }); }); }); From 01763b6cace3399dad617600a9e5012e2d78ea2b Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 24 Mar 2026 19:36:47 +0100 Subject: [PATCH 11/23] Linting --- .../a11y/src/components/A11yContext.tsx | 349 +++++++----------- 1 file changed, 137 insertions(+), 212 deletions(-) diff --git a/code/addons/a11y/src/components/A11yContext.tsx b/code/addons/a11y/src/components/A11yContext.tsx index a5733cd5bf6e..4047c37d3887 100644 --- a/code/addons/a11y/src/components/A11yContext.tsx +++ b/code/addons/a11y/src/components/A11yContext.tsx @@ -1,4 +1,4 @@ -import type { FC, PropsWithChildren } from "react"; +import type { FC, PropsWithChildren } from 'react'; import React, { createContext, useCallback, @@ -7,7 +7,7 @@ import React, { useMemo, useRef, useState, -} from "react"; +} from 'react'; import { STORY_CHANGED, @@ -15,14 +15,10 @@ import { STORY_HOT_UPDATED, STORY_RENDER_PHASE_CHANGED, type StoryFinishedPayload, -} from "storybook/internal/core-events"; +} from 'storybook/internal/core-events'; -import type { ClickEventDetails, HighlightMenuItem } from "storybook/highlight"; -import { - HIGHLIGHT, - REMOVE_HIGHLIGHT, - SCROLL_INTO_VIEW, -} from "storybook/highlight"; +import type { ClickEventDetails, HighlightMenuItem } from 'storybook/highlight'; +import { HIGHLIGHT, REMOVE_HIGHLIGHT, SCROLL_INTO_VIEW } from 'storybook/highlight'; import { experimental_getStatusStore, experimental_useStatusStore, @@ -32,33 +28,20 @@ import { useParameter, useStorybookApi, useStorybookState, -} from "storybook/manager-api"; -import type { Report } from "storybook/preview-api"; -import { convert, themes } from "storybook/theming"; +} from 'storybook/manager-api'; +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 type { A11yParameters } from "../params"; -import type { - A11yReport, - EnhancedResult, - EnhancedResults, - Status, -} from "../types"; -import { RuleType } from "../types"; -import type { TestDiscrepancy } from "./TestDiscrepancyMessage"; +import { getFriendlySummaryForAxeResult, getTitleForAxeResult } from '../axeRuleMappingHelper'; +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, Status } from '../types'; +import { RuleType } from '../types'; +import type { TestDiscrepancy } from './TestDiscrepancyMessage'; // These elements should not be highlighted because they usually cover the whole page. // They may still appear in the results and be selectable though. -const unhighlightedSelectors = ["html", "body", "main"]; +const unhighlightedSelectors = ['html', 'body', 'main']; export interface A11yContextStore { parameters: A11yParameters; @@ -74,11 +57,7 @@ export interface A11yContextStore { handleManual: () => void; discrepancy: TestDiscrepancy; selectedItems: Map; - toggleOpen: ( - event: React.SyntheticEvent, - type: RuleType, - item: EnhancedResult, - ) => void; + toggleOpen: (event: React.SyntheticEvent, type: RuleType, item: EnhancedResult) => void; allExpanded: boolean; handleCollapseAll: () => void; handleExpandAll: () => void; @@ -102,7 +81,7 @@ export const A11yContext = createContext({ handleCopyLink: () => {}, setTab: () => {}, setStatus: () => {}, - status: "initial", + status: 'initial', error: undefined, handleManual: () => {}, discrepancy: null, @@ -116,25 +95,19 @@ export const A11yContext = createContext({ }); export const A11yContextProvider: FC = (props) => { - const parameters = useParameter("a11y", {}); + const parameters = useParameter('a11y', {}); const [globals] = useGlobals() ?? []; const api = useStorybookApi(); - const getInitialStatus = useCallback( - (manual = false) => (manual ? "manual" : "initial"), - [], - ); + const getInitialStatus = useCallback((manual = false) => (manual ? 'manual' : 'initial'), []); - const manual = useMemo( - () => globals?.a11y?.manual ?? false, - [globals?.a11y?.manual], - ); + const manual = useMemo(() => globals?.a11y?.manual ?? false, [globals?.a11y?.manual]); const a11ySelection = useMemo(() => { - const value = api.getQueryParam("a11ySelection"); + const value = api.getQueryParam('a11ySelection'); if (value) { - api.setQueryParams({ a11ySelection: "" }); + api.setQueryParams({ a11ySelection: '' }); } return value; }, [api]); @@ -158,23 +131,19 @@ export const A11yContextProvider: FC = (props) => { const { storyId } = useStorybookState(); const currentStoryA11yStatusValue = experimental_useStatusStore( - (allStatuses) => allStatuses[storyId]?.[STATUS_TYPE_ID_A11Y]?.value, + (allStatuses) => allStatuses[storyId]?.[STATUS_TYPE_ID_A11Y]?.value ); useEffect(() => { - const unsubscribe = experimental_getStatusStore( - "storybook/component-test", - ).onAllStatusChange((statuses, previousStatuses) => { - 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" - ) { - setState((prev) => ({ ...prev, status: "component-test-error" })); + const unsubscribe = experimental_getStatusStore('storybook/component-test').onAllStatusChange( + (statuses, previousStatuses) => { + 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') { + setState((prev) => ({ ...prev, status: 'component-test-error' })); + } } - }); + ); return unsubscribe; }, [setState, storyId]); @@ -195,42 +164,30 @@ export const A11yContextProvider: FC = (props) => { }; }, []); - const [selectedItems, setSelectedItems] = useState>( - () => { - const initialValue = new Map(); - // Check if the a11ySelection param is a valid format before parsing it - // It should look like `violation.aria-hidden-body.1` - if (a11ySelection && /^[a-z]+.[a-z-]+.[0-9]+$/.test(a11ySelection)) { - const [type, id] = a11ySelection.split("."); - initialValue.set(`${type}.${id}`, a11ySelection); - } - return initialValue; - }, - ); + const [selectedItems, setSelectedItems] = useState>(() => { + const initialValue = new Map(); + // Check if the a11ySelection param is a valid format before parsing it + // It should look like `violation.aria-hidden-body.1` + if (a11ySelection && /^[a-z]+.[a-z-]+.[0-9]+$/.test(a11ySelection)) { + const [type, id] = a11ySelection.split('.'); + initialValue.set(`${type}.${id}`, a11ySelection); + } + return initialValue; + }); // All items are expanded if something is selected from each result for the current tab const allExpanded = useMemo(() => { const currentResults = results?.[ui.tab]; - return ( - currentResults?.every((result) => - selectedItems.has(`${ui.tab}.${result.id}`), - ) ?? false - ); + 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, - ) => { + (event: React.SyntheticEvent, type: RuleType, item: EnhancedResult) => { event.stopPropagation(); const key = `${type}.${item.id}`; - setSelectedItems( - (prev) => new Map(prev.delete(key) ? prev : prev.set(key, `${key}.1`)), - ); + setSelectedItems((prev) => new Map(prev.delete(key) ? prev : prev.set(key, `${key}.1`))); }, - [], + [] ); const handleCollapseAll = useCallback(() => { @@ -244,27 +201,27 @@ export const A11yContextProvider: FC = (props) => { results?.[ui.tab]?.map((result) => { const key = `${ui.tab}.${result.id}`; return [key, prev.get(key) ?? `${key}.1`]; - }) ?? [], - ), + }) ?? [] + ) ); }, [results, ui.tab]); const handleSelectionChange = useCallback((key: string) => { - const [type, id] = key.split("."); + const [type, id] = key.split('.'); setSelectedItems((prev) => new Map(prev.set(`${type}.${id}`, key))); }, []); const handleError = useCallback( (err: unknown) => { - setState((prev) => ({ ...prev, status: "error", error: err })); + setState((prev) => ({ ...prev, status: 'error', error: err })); }, - [setState], + [setState] ); const handleResult = useCallback( (axeResults: EnhancedResults, id: string) => { if (storyId === id) { - setState((prev) => ({ ...prev, status: "ran", results: axeResults })); + setState((prev) => ({ ...prev, status: 'ran', results: axeResults })); if (statusTimerRef.current !== null) { clearTimeout(statusTimerRef.current); @@ -272,82 +229,72 @@ export const A11yContextProvider: FC = (props) => { statusTimerRef.current = setTimeout(() => { statusTimerRef.current = null; setState((prev) => { - if (prev.status === "ran") { - return { ...prev, status: "ready" }; + 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" }); + document.getElementById(key)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } return prev; }); }, 900); } }, - [storyId, setState, setSelectedItems], + [storyId, setState, setSelectedItems] ); const handleSelect = useCallback( (itemId: string, details: ClickEventDetails) => { - const [type, id] = itemId.split("."); - const { helpUrl, nodes } = - results?.[type as RuleType]?.find((r) => r.id === id) || {}; - const openedWindow = - helpUrl && window.open(helpUrl, "_blank", "noopener,noreferrer"); + const [type, id] = itemId.split('.'); + const { helpUrl, nodes } = results?.[type as RuleType]?.find((r) => r.id === id) || {}; + const openedWindow = helpUrl && window.open(helpUrl, '_blank', 'noopener,noreferrer'); if (nodes && !openedWindow) { const index = - nodes.findIndex((n) => - details.selectors.some((s) => s === String(n.target)), - ) ?? -1; + nodes.findIndex((n) => details.selectors.some((s) => s === String(n.target))) ?? -1; if (index !== -1) { const key = `${type}.${id}.${index + 1}`; setSelectedItems(new Map([[`${type}.${id}`, key]])); setTimeout(() => { - document - .getElementById(key) - ?.scrollIntoView({ behavior: "smooth", block: "center" }); + document.getElementById(key)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); } } }, - [results], + [results] ); const handleReport = useCallback( ({ reporters }: StoryFinishedPayload) => { - const a11yReport = reporters.find((r) => r.type === "a11y") as - | Report - | undefined; + const a11yReport = reporters.find((r) => r.type === 'a11y') as Report | undefined; if (a11yReport) { - if ("error" in a11yReport.result) { + if ('error' in a11yReport.result) { handleError(a11yReport.result.error); } else { handleResult(a11yReport.result, storyId); } } }, - [handleError, handleResult, storyId], + [handleError, handleResult, storyId] ); const handleReset = useCallback( ({ newPhase }: { newPhase: string }) => { - if (newPhase === "loading") { + if (newPhase === 'loading') { setState((prev) => ({ ...prev, results: undefined, - status: manual ? "manual" : "initial", + status: manual ? 'manual' : 'initial', })); - } else if (newPhase === "afterEach" && !manual) { - setState((prev) => ({ ...prev, status: "running" })); + } else if (newPhase === 'afterEach' && !manual) { + setState((prev) => ({ ...prev, status: 'running' })); } }, - [manual, setState], + [manual, setState] ); const emit = useChannel( @@ -359,44 +306,33 @@ export const A11yContextProvider: FC = (props) => { [STORY_RENDER_PHASE_CHANGED]: handleReset, [STORY_FINISHED]: handleReport, [STORY_HOT_UPDATED]: () => { - setState((prev) => ({ ...prev, status: "running" })); + setState((prev) => ({ ...prev, status: 'running' })); emit(EVENTS.MANUAL, storyId, parameters); }, }, - [ - handleReset, - handleReport, - handleSelect, - handleError, - handleResult, - parameters, - storyId, - ], + [handleReset, handleReport, handleSelect, handleError, handleResult, parameters, storyId] ); const handleManual = useCallback(() => { - setState((prev) => ({ ...prev, status: "running" })); + setState((prev) => ({ ...prev, status: 'running' })); emit(EVENTS.MANUAL, storyId, parameters); }, [emit, parameters, setState, storyId]); const handleCopyLink = useCallback(async (linkPath: string) => { - const { createCopyToClipboardFunction } = - await import("storybook/internal/components"); - await createCopyToClipboardFunction()( - `${window.location.origin}${linkPath}`, - ); + const { createCopyToClipboardFunction } = await import('storybook/internal/components'); + await createCopyToClipboardFunction()(`${window.location.origin}${linkPath}`); }, []); const handleJumpToElement = useCallback( (target: string) => emit(SCROLL_INTO_VIEW, target), - [emit], + [emit] ); useEffect(() => { setState((prev) => ({ ...prev, status: getInitialStatus(manual) })); }, [getInitialStatus, manual, setState]); - const isInitial = status === "initial"; + const isInitial = status === 'initial'; // If a deep link is provided, prefer it once on mount and persist UI state accordingly useEffect(() => { @@ -406,7 +342,7 @@ export const A11yContextProvider: FC = (props) => { setState((prev) => { const update = { ...prev.ui, highlighted: true }; - const [type] = a11ySelection.split(".") ?? []; + const [type] = a11ySelection.split('.') ?? []; if (type && Object.values(RuleType).includes(type as RuleType)) { update.tab = type as RuleType; } @@ -426,7 +362,7 @@ export const A11yContextProvider: FC = (props) => { } const selected = Array.from(selectedItems.values()).flatMap((key) => { - const [type, id, number] = key.split("."); + const [type, id, number] = key.split('.'); if (type !== ui.tab) { return []; } @@ -441,38 +377,36 @@ export const A11yContextProvider: FC = (props) => { selectors: selected, styles: { outline: `1px solid color-mix(in srgb, ${colorsByType[ui.tab]}, transparent 30%)`, - backgroundColor: "transparent", + backgroundColor: 'transparent', }, hoverStyles: { - outlineWidth: "2px", + outlineWidth: '2px', }, focusStyles: { - backgroundColor: "transparent", + backgroundColor: 'transparent', }, - 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: `${ui.tab}.${result.id}:info`, - title: getTitleForAxeResult(result), - description: getFriendlySummaryForAxeResult(result), - selectors, - }, - { - id: `${ui.tab}.${result.id}`, - iconLeft: "info", - iconRight: "shareAlt", - title: "Learn how to resolve this violation", - clickEvent: EVENTS.SELECT, - selectors, - }, - ]; - }, - ), + 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: `${ui.tab}.${result.id}:info`, + title: getTitleForAxeResult(result), + description: getFriendlySummaryForAxeResult(result), + selectors, + }, + { + id: `${ui.tab}.${result.id}`, + iconLeft: 'info', + iconRight: 'shareAlt', + title: 'Learn how to resolve this violation', + clickEvent: EVENTS.SELECT, + selectors, + }, + ]; + }), }); } @@ -488,35 +422,33 @@ export const A11yContextProvider: FC = (props) => { backgroundColor: `color-mix(in srgb, ${colorsByType[ui.tab]}, transparent 60%)`, }, hoverStyles: { - outlineWidth: "2px", + outlineWidth: '2px', }, focusStyles: { - backgroundColor: "transparent", + backgroundColor: 'transparent', }, - 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: `${ui.tab}.${result.id}:info`, - title: getTitleForAxeResult(result), - description: getFriendlySummaryForAxeResult(result), - selectors, - }, - { - id: `${ui.tab}.${result.id}`, - iconLeft: "info", - iconRight: "shareAlt", - title: "Learn how to resolve this violation", - clickEvent: EVENTS.SELECT, - selectors, - }, - ]; - }, - ), + 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: `${ui.tab}.${result.id}:info`, + title: getTitleForAxeResult(result), + description: getFriendlySummaryForAxeResult(result), + selectors, + }, + { + id: `${ui.tab}.${result.id}`, + iconLeft: 'info', + iconRight: 'shareAlt', + title: 'Learn how to resolve this violation', + clickEvent: EVENTS.SELECT, + selectors, + }, + ]; + }), }); } }, [isInitial, emit, ui.highlighted, results, ui.tab, selectedItems]); @@ -525,23 +457,17 @@ export const A11yContextProvider: FC = (props) => { if (!currentStoryA11yStatusValue) { return null; } - if ( - currentStoryA11yStatusValue === "status-value:success" && - results?.violations.length - ) { - return "cliPassedBrowserFailed"; + if (currentStoryA11yStatusValue === 'status-value:success' && results?.violations.length) { + return 'cliPassedBrowserFailed'; } - if ( - currentStoryA11yStatusValue === "status-value:error" && - !results?.violations.length - ) { - if (status === "ready" || status === "ran") { - return "browserPassedCliFailed"; + if (currentStoryA11yStatusValue === 'status-value:error' && !results?.violations.length) { + if (status === 'ready' || status === 'ran') { + return 'browserPassedCliFailed'; } - if (status === "manual") { - return "cliFailedButModeManual"; + if (status === 'manual') { + return 'cliFailedButModeManual'; } } return null; @@ -556,15 +482,14 @@ export const A11yContextProvider: FC = (props) => { toggleHighlight: handleToggleHighlight, tab: ui.tab, setTab: useCallback( - (type: RuleType) => - setState((prev) => ({ ...prev, ui: { ...prev.ui, tab: type } })), - [setState], + (type: RuleType) => setState((prev) => ({ ...prev, ui: { ...prev.ui, tab: type } })), + [setState] ), handleCopyLink, status: status, setStatus: useCallback( (status: Status) => setState((prev) => ({ ...prev, status })), - [setState], + [setState] ), error: error, handleManual, From 5f923097b26dad128b153473b20d8d7d1f6e0632 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 25 Mar 2026 11:08:53 +0100 Subject: [PATCH 12/23] Core: Add changeDetection feature flag --- code/core/src/core-server/presets/common-preset.ts | 1 + code/core/src/types/modules/core-common.ts | 7 +++++++ docs/api/main-config/main-config-features.mdx | 11 +++++++++++ 3 files changed, 19 insertions(+) diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index c3479f2e42b1..6054ce5024e7 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -217,6 +217,7 @@ export const features: PresetProperty<'features'> = async (existing) => ({ measure: true, sidebarOnboardingChecklist: true, componentsManifest: true, + changeDetection: false, }); export const csfIndexer: Indexer = { diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index 5f6339dd09e8..4b5e046df196 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -535,6 +535,13 @@ export interface StorybookConfigRaw { * @experimental This feature is in early development and may change significantly in future releases. */ experimentalCodeExamples?: boolean; + + /** + * Enable change detection + * TODO: Turn to true before 10.4 release + * @default false + */ + changeDetection?: boolean; }; build?: TestBuildConfig; diff --git a/docs/api/main-config/main-config-features.mdx b/docs/api/main-config/main-config-features.mdx index 6f7d63370219..f119c8d2239b 100644 --- a/docs/api/main-config/main-config-features.mdx +++ b/docs/api/main-config/main-config-features.mdx @@ -16,6 +16,7 @@ Type: actions?: boolean; argTypeTargetsV7?: boolean; backgrounds?: boolean; + changeDetection?: boolean; componentsManifest?: boolean; controls?: boolean; developmentModeForBuild?: boolean; @@ -41,6 +42,7 @@ Type: angularFilterNonInputControls?: boolean; argTypeTargetsV7?: boolean; backgrounds?: boolean; + changeDetection?: boolean; controls?: boolean; developmentModeForBuild?: boolean; highlight?: boolean; @@ -62,6 +64,7 @@ Type: actions?: boolean; argTypeTargetsV7?: boolean; backgrounds?: boolean; + changeDetection?: boolean; controls?: boolean; developmentModeForBuild?: boolean; highlight?: boolean; @@ -135,6 +138,14 @@ Generate [manifests](../../ai/manifests.mdx), used by the [MCP server](../../ai/ +## `changeDetection` + +Type: `boolean` + +Default: `true` + +Enable change detection. + ## `controls` Type: `boolean` From f1256e98deeb842f6be7520b330562f4516bc509 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 25 Mar 2026 11:31:56 +0100 Subject: [PATCH 13/23] Fix tests --- code/core/src/manager-api/tests/store.test.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/code/core/src/manager-api/tests/store.test.js b/code/core/src/manager-api/tests/store.test.js index e77bc1cd289e..58c43c7eadf6 100644 --- a/code/core/src/manager-api/tests/store.test.js +++ b/code/core/src/manager-api/tests/store.test.js @@ -21,8 +21,13 @@ vi.mock('store2', () => ({ describe('store', () => { it('sensibly combines local+session storage for initial state', () => { - store2.session.get.mockReturnValueOnce({ foo: 'bar', combined: { a: 'b' } }); - store2.local.get.mockReturnValueOnce({ foo: 'baz', another: 'value', combined: { c: 'd' } }); + // Each storage is read twice: once for the migration check, once for the actual merge. + store2.session.get + .mockReturnValueOnce({ foo: 'bar', combined: { a: 'b' } }) + .mockReturnValueOnce({ foo: 'bar', combined: { a: 'b' } }); + store2.local.get + .mockReturnValueOnce({ foo: 'baz', another: 'value', combined: { c: 'd' } }) + .mockReturnValueOnce({ foo: 'baz', another: 'value', combined: { c: 'd' } }); const store = new Store({}); expect(store.getInitialState()).toEqual({ From 7f4b44e3d2fbec4ee01641510791d2a51d6502d8 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 25 Mar 2026 14:17:31 +0100 Subject: [PATCH 14/23] Formatting --- .../src/core-server/presets/common-preset.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index 6054ce5024e7..60a2787a8176 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -204,20 +204,20 @@ export const core = async (existing: CoreConfig, options: Options): Promise = async (existing) => ({ ...existing, + actions: true, argTypeTargetsV7: true, - legacyDecoratorFileOrder: false, + backgrounds: true, + changeDetection: false, + componentsManifest: true, + controls: true, disallowImplicitActionsInRenderV8: true, - viewport: true, highlight: true, - controls: true, interactions: true, - actions: true, - backgrounds: true, - outline: true, + legacyDecoratorFileOrder: false, measure: true, + outline: true, sidebarOnboardingChecklist: true, - componentsManifest: true, - changeDetection: false, + viewport: true, }); export const csfIndexer: Indexer = { From a72ebc8aceeb2d8716d5cb03776c38b9675c2eb5 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 25 Mar 2026 15:23:31 +0100 Subject: [PATCH 15/23] Don't check format of version JSON files --- .oxfmtrc.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.oxfmtrc.json b/.oxfmtrc.json index fa3458fee14e..3bea666e8a1b 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -36,7 +36,8 @@ "*.yaml", "*.md", "*.mdx", - "!docs/_snippets/**" + "!docs/_snippets/**", + "!docs/versions/*.json" ], "overrides": [ { @@ -59,7 +60,10 @@ } }, { - "files": ["**/frameworks/angular/src/**/*.ts", "**/frameworks/angular/template/**/*.ts"], + "files": [ + "**/frameworks/angular/src/**/*.ts", + "**/frameworks/angular/template/**/*.ts" + ], "options": { "parser": "babel-ts" } From 1a90d3b074210946c3cc16b39142a43d66e28ebc Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 25 Mar 2026 15:42:27 +0100 Subject: [PATCH 16/23] =?UTF-8?q?fix=20format=20=F0=9F=99=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .oxfmtrc.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 3bea666e8a1b..39b33f7eba30 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -60,10 +60,7 @@ } }, { - "files": [ - "**/frameworks/angular/src/**/*.ts", - "**/frameworks/angular/template/**/*.ts" - ], + "files": ["**/frameworks/angular/src/**/*.ts", "**/frameworks/angular/template/**/*.ts"], "options": { "parser": "babel-ts" } From 78b7e12e19299cfe852b89cfb441990d883af993 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 26 Mar 2026 10:17:03 +0100 Subject: [PATCH 17/23] Apply requested changes --- code/core/src/manager-api/lib/url.ts | 10 ++++++++++ code/core/src/manager-api/modules/stories.ts | 8 ++------ code/core/src/manager-api/modules/tags.ts | 7 ++++--- code/core/src/manager-api/modules/url.ts | 8 ++------ code/core/src/manager-api/store.ts | 1 + code/core/src/manager-api/tests/tags.test.js | 16 ++++++---------- 6 files changed, 25 insertions(+), 25 deletions(-) create mode 100644 code/core/src/manager-api/lib/url.ts diff --git a/code/core/src/manager-api/lib/url.ts b/code/core/src/manager-api/lib/url.ts new file mode 100644 index 000000000000..da854316e13d --- /dev/null +++ b/code/core/src/manager-api/lib/url.ts @@ -0,0 +1,10 @@ +export const buildNavigationUrl = ( + path: string, + queryParams: Record = {} +): string => { + const params = Object.entries(queryParams) + .filter(([, v]) => v !== null && v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : 1)) + .map(([k, v]) => `${k}=${v}`); + return [path, ...params].join('&'); +}; diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index a0212534900d..c26daebd5b72 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -63,6 +63,7 @@ import { transformStoryIndexToStoriesHash, } from '../lib/stories'; import type { ModuleFn } from '../lib/types'; +import { buildNavigationUrl } from '../lib/url'; import type { ComposedRef } from '../root'; import { fullStatusStore } from '../stores/status'; import { parseTagsParam, serializeTagsParam } from './tags'; @@ -436,12 +437,7 @@ export const init: ModuleFn = ({ }) => { const navigateWithQueryParams = (path: string, options?: NavigateOptions) => { const { customQueryParams } = store.getState(); - const params = Object.entries(customQueryParams ?? {}) - .filter(([, v]) => v !== null && v !== undefined) - .sort(([a], [b]) => (a < b ? -1 : 1)) - .map(([k, v]) => `${k}=${v}`); - const to = [path, ...params].join('&'); - navigate(to, options); + navigate(buildNavigationUrl(path, customQueryParams ?? {}), options); }; const persistFilters = (inputPatch: Parameters[0]) => { diff --git a/code/core/src/manager-api/modules/tags.ts b/code/core/src/manager-api/modules/tags.ts index 0889adc0fba5..37d3c797704c 100644 --- a/code/core/src/manager-api/modules/tags.ts +++ b/code/core/src/manager-api/modules/tags.ts @@ -1,7 +1,6 @@ import type { Tag } from 'storybook/internal/types'; export const BUILT_IN_URL_TAG_MAP: Record = { - $changed: '_changed', $docs: '_docs', $play: '_play', $test: '_test', @@ -45,8 +44,10 @@ export const serializeTagsParam = (included: Tag[], excluded: Tag[]): string => Object.entries(BUILT_IN_URL_TAG_MAP).map(([urlTag, internalTag]) => [internalTag, urlTag]) ) as Record; - const serializedIncluded = included.map((tag) => reverseBuiltInUrlTagMap[tag] ?? tag); - const serializedExcluded = excluded.map((tag) => `!${reverseBuiltInUrlTagMap[tag] ?? tag}`); + const serializedIncluded = included.map((tag) => reverseBuiltInUrlTagMap[tag] ?? tag).sort(); + const serializedExcluded = excluded + .map((tag) => `!${reverseBuiltInUrlTagMap[tag] ?? tag}`) + .sort(); return [...serializedIncluded, ...serializedExcluded].join(';'); }; diff --git a/code/core/src/manager-api/modules/url.ts b/code/core/src/manager-api/modules/url.ts index 4c8c6e615648..81af3ed54ded 100644 --- a/code/core/src/manager-api/modules/url.ts +++ b/code/core/src/manager-api/modules/url.ts @@ -17,6 +17,7 @@ import { stringify } from 'picoquery'; import merge from '../lib/merge'; import type { ModuleArgs, ModuleFn } from '../lib/types'; +import { buildNavigationUrl } from '../lib/url'; import { DEFAULT_BOTTOM_PANEL_HEIGHT, DEFAULT_NAV_SIZE, DEFAULT_RIGHT_PANEL_WIDTH } from './layout'; export interface SubState { @@ -224,12 +225,7 @@ export const init: ModuleFn = (moduleArgs) => { queryParams: Record = {}, options: NavigateOptions = {} ) => { - const params = Object.entries(queryParams) - .filter(([, v]) => v !== null && v !== undefined) - .sort(([a], [b]) => (a < b ? -1 : 1)) - .map(([k, v]) => `${k}=${v}`); - const to = [path, ...params].join('&'); - return navigate(to, options); + return navigate(buildNavigationUrl(path, queryParams), options); }; const api: SubAPI = { diff --git a/code/core/src/manager-api/store.ts b/code/core/src/manager-api/store.ts index 2eb0b2c9babd..e2e686d9be40 100644 --- a/code/core/src/manager-api/store.ts +++ b/code/core/src/manager-api/store.ts @@ -75,6 +75,7 @@ export default class Store { // The assumption is that this will be called once, to initialize the React state // when the module is instantiated getInitialState(base: State) { + // TODO: Remove in SB 11 // One-time migration: tag filter state moved from localStorage to URL persistence. // Remove the old keys so they no longer interfere with URL-derived initial state. for (const storage of [store.local, store.session] as const) { diff --git a/code/core/src/manager-api/tests/tags.test.js b/code/core/src/manager-api/tests/tags.test.js index 2fe89e7ab3da..e878265fb430 100644 --- a/code/core/src/manager-api/tests/tags.test.js +++ b/code/core/src/manager-api/tests/tags.test.js @@ -10,12 +10,10 @@ describe('parseTagsParam', () => { it('parses include/exclude entries and maps known built-in URL tags', () => { expect( - parseTagsParam( - '$changed;$docs;$play;$test;custom;!blocked;!$changed;!$docs;!$play;!$test;!$unknown' - ) + parseTagsParam('$docs;$play;$test;custom;!blocked;!$docs;!$play;!$test;!$unknown') ).toEqual({ - included: ['_changed', '_docs', '_play', '_test', 'custom'], - excluded: ['blocked', '_changed', '_docs', '_play', '_test', '$unknown'], + included: ['_docs', '_play', '_test', 'custom'], + excluded: ['blocked', '_docs', '_play', '_test', '$unknown'], }); }); @@ -35,11 +33,9 @@ describe('serializeTagsParam', () => { it('serializes include/exclude entries and maps known built-in internal tags', () => { expect( serializeTagsParam( - ['_changed', '_docs', '_play', '_test', 'custom', '_unknown'], - ['blocked', '_changed', '_docs', '_play', '_test', '_unknown'] + ['_play', '_docs', '_test', 'custom', '_unknown'], + ['blocked', '_docs', '_play', '_test', '_unknown'] ) - ).toEqual( - '$changed;$docs;$play;$test;custom;_unknown;!blocked;!$changed;!$docs;!$play;!$test;!_unknown' - ); + ).toEqual('$docs;$play;$test;_unknown;custom;!$docs;!$play;!$test;!_unknown;!blocked'); }); }); From cdaa547d01cb0ff667aa2f121593a761bb36df1e Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 26 Mar 2026 10:40:19 +0100 Subject: [PATCH 18/23] Format --- docs/versions/next.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/versions/next.json b/docs/versions/next.json index 7d49aed6ae62..12abd664dec3 100644 --- a/docs/versions/next.json +++ b/docs/versions/next.json @@ -1 +1,6 @@ -{"version":"10.4.0-alpha.4","info":{"plain":"- Addon-Docs: Add Reset story button to re-render stories in docs - [#34086](https://github.com/storybookjs/storybook/pull/34086), thanks @6810779s!\n- Code: Fix inline code blocks inside links removing link affordance - [#33903](https://github.com/storybookjs/storybook/pull/33903), thanks @yatishgoel!\n- Controls: Add maxPresetColors option to ColorControl - [#33998](https://github.com/storybookjs/storybook/pull/33998), thanks @mixelburg!\n- Core: Fix WebSocket connection for StackBlitz/WebContainers - [#34281](https://github.com/storybookjs/storybook/pull/34281), thanks @ghengeveld!\n- Dependencies: Update `vite-plugin-storybook-nextjs` to ^3.2.4 - [#34280](https://github.com/storybookjs/storybook/pull/34280), thanks @k35o!\n- React: Add component metadata extraction via Volar-style LanguageService - [#33914](https://github.com/storybookjs/storybook/pull/33914), thanks @kasperpeulen!\n- StatusValue: Add 'status-value:' - [#34305](https://github.com/storybookjs/storybook/pull/34305), thanks @valentinpalkovic!\n- UI: Ensure Controls panel can scroll horizontally for now - [#34248](https://github.com/storybookjs/storybook/pull/34248), thanks @Sidnioulz!"}} \ No newline at end of file +{ + "version": "10.4.0-alpha.4", + "info": { + "plain": "- Addon-Docs: Add Reset story button to re-render stories in docs - [#34086](https://github.com/storybookjs/storybook/pull/34086), thanks @6810779s!\n- Code: Fix inline code blocks inside links removing link affordance - [#33903](https://github.com/storybookjs/storybook/pull/33903), thanks @yatishgoel!\n- Controls: Add maxPresetColors option to ColorControl - [#33998](https://github.com/storybookjs/storybook/pull/33998), thanks @mixelburg!\n- Core: Fix WebSocket connection for StackBlitz/WebContainers - [#34281](https://github.com/storybookjs/storybook/pull/34281), thanks @ghengeveld!\n- Dependencies: Update `vite-plugin-storybook-nextjs` to ^3.2.4 - [#34280](https://github.com/storybookjs/storybook/pull/34280), thanks @k35o!\n- React: Add component metadata extraction via Volar-style LanguageService - [#33914](https://github.com/storybookjs/storybook/pull/33914), thanks @kasperpeulen!\n- StatusValue: Add 'status-value:' - [#34305](https://github.com/storybookjs/storybook/pull/34305), thanks @valentinpalkovic!\n- UI: Ensure Controls panel can scroll horizontally for now - [#34248](https://github.com/storybookjs/storybook/pull/34248), thanks @Sidnioulz!" + } +} From b1ebc9786e22e525b10f2584ed532d9d72ec14fe Mon Sep 17 00:00:00 2001 From: Jack Preston Date: Thu, 26 Mar 2026 09:51:19 +0000 Subject: [PATCH 19/23] React-Vite: Upgrade @joshwooding/vite-plugin-react-docgen-typescript to 0.7.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- code/frameworks/react-vite/package.json | 2 +- yarn.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/code/frameworks/react-vite/package.json b/code/frameworks/react-vite/package.json index d87bc6cd1029..814a73eddaed 100644 --- a/code/frameworks/react-vite/package.json +++ b/code/frameworks/react-vite/package.json @@ -52,7 +52,7 @@ "!src/**/*" ], "dependencies": { - "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.4", + "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0", "@rollup/pluginutils": "^5.0.2", "@storybook/builder-vite": "workspace:*", "@storybook/react": "workspace:*", diff --git a/yarn.lock b/yarn.lock index fd7c58c042a9..cee8c18346d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3376,19 +3376,19 @@ __metadata: languageName: node linkType: hard -"@joshwooding/vite-plugin-react-docgen-typescript@npm:^0.6.4": - version: 0.6.4 - resolution: "@joshwooding/vite-plugin-react-docgen-typescript@npm:0.6.4" +"@joshwooding/vite-plugin-react-docgen-typescript@npm:^0.7.0": + version: 0.7.0 + resolution: "@joshwooding/vite-plugin-react-docgen-typescript@npm:0.7.0" dependencies: glob: "npm:^13.0.1" react-docgen-typescript: "npm:^2.2.2" peerDependencies: typescript: ">= 4.3.x" - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: typescript: optional: true - checksum: 10c0/73149b2d41d5b8eff7dfe4d037a6903fe4123ae46f3928d88535020539f44159c4ea1b342e6a77d4c14219f2f743fea0ef96e81279cce8b6d247dc4d582e27ed + checksum: 10c0/6d1a353e4dd0d9d641beafcf8d5c36805ad7f916ae07b817642033bc85c388f819f92dc94db192117dedfaa5d981ac5ef72911315e3e4bf2fe9e23d8956618e6 languageName: node linkType: hard @@ -8613,7 +8613,7 @@ __metadata: version: 0.0.0-use.local resolution: "@storybook/react-vite@workspace:code/frameworks/react-vite" dependencies: - "@joshwooding/vite-plugin-react-docgen-typescript": "npm:^0.6.4" + "@joshwooding/vite-plugin-react-docgen-typescript": "npm:^0.7.0" "@rollup/pluginutils": "npm:^5.0.2" "@storybook/builder-vite": "workspace:*" "@storybook/react": "workspace:*" From 747b61360aa2226f81dceab137a016c3fae7a94d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:51:38 +0000 Subject: [PATCH 20/23] Initial plan From 776a248ac847d90ffa49da362bb65cde8ffd965e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:58:51 +0000 Subject: [PATCH 21/23] refactor: extract shared PseudoStateGrid component to eliminate duplicate code in pseudo-states stories Co-authored-by: valentinpalkovic <5889929+valentinpalkovic@users.noreply.github.com> Agent-Logs-Url: https://github.com/storybookjs/storybook/sessions/d64da8e9-5f2a-402f-92c2-bc4185d77af0 --- .../src/stories/Button.stories.tsx | 28 +------------ .../src/stories/CSSAtRules.stories.tsx | 28 +------------ .../src/stories/CustomElement.stories.tsx | 41 ++++--------------- .../stories/CustomElementNested.stories.tsx | 41 ++++--------------- .../src/stories/PseudoStateGrid.tsx | 20 +++++++++ .../src/stories/ShadowRoot.stories.tsx | 30 +------------- .../stories/ShadowRootWithPart.stories.tsx | 30 +------------- 7 files changed, 42 insertions(+), 176 deletions(-) create mode 100644 code/addons/pseudo-states/src/stories/PseudoStateGrid.tsx diff --git a/code/addons/pseudo-states/src/stories/Button.stories.tsx b/code/addons/pseudo-states/src/stories/Button.stories.tsx index 1dadc4bb90c5..44fe8351eeca 100644 --- a/code/addons/pseudo-states/src/stories/Button.stories.tsx +++ b/code/addons/pseudo-states/src/stories/Button.stories.tsx @@ -5,6 +5,7 @@ import { styled } from 'storybook/theming'; import { Button } from './Button'; import './grid.css'; +import { PseudoStateGrid } from './PseudoStateGrid'; const meta = { title: 'Button', @@ -18,32 +19,7 @@ type Story = StoryObj; export const All: Story = { render: (args: ComponentProps) => ( -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
+ } /> ), }; diff --git a/code/addons/pseudo-states/src/stories/CSSAtRules.stories.tsx b/code/addons/pseudo-states/src/stories/CSSAtRules.stories.tsx index 4f9e7fc01c28..16b7f6c50a78 100644 --- a/code/addons/pseudo-states/src/stories/CSSAtRules.stories.tsx +++ b/code/addons/pseudo-states/src/stories/CSSAtRules.stories.tsx @@ -8,6 +8,7 @@ import { useChannel, useStoryContext } from 'storybook/preview-api'; import { Button } from './CSSAtRules'; import './grid.css'; +import { PseudoStateGrid } from './PseudoStateGrid'; const meta = { title: 'CSSAtRules', @@ -21,32 +22,7 @@ type Story = StoryObj; export const All: Story = { render: (args: ComponentProps) => ( -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
+ } /> ), }; diff --git a/code/addons/pseudo-states/src/stories/CustomElement.stories.tsx b/code/addons/pseudo-states/src/stories/CustomElement.stories.tsx index eaed0e1a4da9..d490eacf9553 100644 --- a/code/addons/pseudo-states/src/stories/CustomElement.stories.tsx +++ b/code/addons/pseudo-states/src/stories/CustomElement.stories.tsx @@ -4,6 +4,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import './CustomElement'; import './grid.css'; +import { PseudoStateGrid } from './PseudoStateGrid'; const meta = { title: 'CustomElement', @@ -20,40 +21,12 @@ type Story = StoryObj; export const All: Story = { render: () => ( -
-
- {/* @ts-expect-error We're dealing with a web component here */} - Normal -
-
- {/* @ts-expect-error We're dealing with a web component here */} - Hover -
-
- {/* @ts-expect-error We're dealing with a web component here */} - Focus -
-
- {/* @ts-expect-error We're dealing with a web component here */} - Active -
-
- {/* @ts-expect-error We're dealing with a web component here */} - Hover Focus -
-
- {/* @ts-expect-error We're dealing with a web component here */} - Hover Active -
-
- {/* @ts-expect-error We're dealing with a web component here */} - Focus Active -
-
- {/* @ts-expect-error We're dealing with a web component here */} - Hover Focus Active -
-
+ ( + // @ts-expect-error We're dealing with a web component here + {label} + )} + /> ), }; diff --git a/code/addons/pseudo-states/src/stories/CustomElementNested.stories.tsx b/code/addons/pseudo-states/src/stories/CustomElementNested.stories.tsx index 4c004bd33903..5a206b07114b 100644 --- a/code/addons/pseudo-states/src/stories/CustomElementNested.stories.tsx +++ b/code/addons/pseudo-states/src/stories/CustomElementNested.stories.tsx @@ -4,6 +4,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import './CustomElementNested'; import './grid.css'; +import { PseudoStateGrid } from './PseudoStateGrid'; const meta = { title: 'CustomElementNested', @@ -20,40 +21,12 @@ type Story = StoryObj; export const All: Story = { render: () => ( -
-
- {/* @ts-expect-error We're dealing with a web component here */} - Normal -
-
- {/* @ts-expect-error We're dealing with a web component here */} - Hover -
-
- {/* @ts-expect-error We're dealing with a web component here */} - Focus -
-
- {/* @ts-expect-error We're dealing with a web component here */} - Active -
-
- {/* @ts-expect-error We're dealing with a web component here */} - Hover Focus -
-
- {/* @ts-expect-error We're dealing with a web component here */} - Hover Active -
-
- {/* @ts-expect-error We're dealing with a web component here */} - Focus Active -
-
- {/* @ts-expect-error We're dealing with a web component here */} - Hover Focus Active -
-
+ ( + // @ts-expect-error We're dealing with a web component here + {label} + )} + /> ), }; diff --git a/code/addons/pseudo-states/src/stories/PseudoStateGrid.tsx b/code/addons/pseudo-states/src/stories/PseudoStateGrid.tsx new file mode 100644 index 000000000000..fee7199453ba --- /dev/null +++ b/code/addons/pseudo-states/src/stories/PseudoStateGrid.tsx @@ -0,0 +1,20 @@ +import React, { type ReactNode } from 'react'; + +interface PseudoStateGridProps { + render: (label: string) => ReactNode; +} + +export const PseudoStateGrid = ({ render }: PseudoStateGridProps) => ( +
+
{render('Normal')}
+
{render('Hover')}
+
{render('Focus')}
+
{render('Active')}
+
{render('Hover Focus')}
+
{render('Hover Active')}
+
{render('Focus Active')}
+
+ {render('Hover Focus Active')} +
+
+); diff --git a/code/addons/pseudo-states/src/stories/ShadowRoot.stories.tsx b/code/addons/pseudo-states/src/stories/ShadowRoot.stories.tsx index f8d11a84d290..60eedc5131e3 100644 --- a/code/addons/pseudo-states/src/stories/ShadowRoot.stories.tsx +++ b/code/addons/pseudo-states/src/stories/ShadowRoot.stories.tsx @@ -4,6 +4,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { ShadowRoot } from './ShadowRoot'; import './grid.css'; +import { PseudoStateGrid } from './PseudoStateGrid'; const meta = { title: 'ShadowRoot', @@ -15,34 +16,7 @@ export default meta; type Story = StoryObj; export const All: Story = { - render: () => ( -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- ), + render: () => } />, }; export const Default: Story = {}; diff --git a/code/addons/pseudo-states/src/stories/ShadowRootWithPart.stories.tsx b/code/addons/pseudo-states/src/stories/ShadowRootWithPart.stories.tsx index 8dae2e4b111e..e24fc6f3f763 100644 --- a/code/addons/pseudo-states/src/stories/ShadowRootWithPart.stories.tsx +++ b/code/addons/pseudo-states/src/stories/ShadowRootWithPart.stories.tsx @@ -4,6 +4,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { ShadowRoot } from './ShadowRootWithPart'; import './grid.css'; +import { PseudoStateGrid } from './PseudoStateGrid'; const meta = { title: 'ShadowRootWithPart', @@ -15,34 +16,7 @@ export default meta; type Story = StoryObj; export const All: Story = { - render: () => ( -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- ), + render: () => } />, }; export const Default: Story = {}; From 382d78b85467533c038cd6ecf8b1d83128970fc6 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 26 Mar 2026 12:44:33 +0100 Subject: [PATCH 22/23] Formatting --- .../src/automigrate/fixes/nextjs-to-nextjs-vite.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/code/lib/cli-storybook/src/automigrate/fixes/nextjs-to-nextjs-vite.ts b/code/lib/cli-storybook/src/automigrate/fixes/nextjs-to-nextjs-vite.ts index 7f914eb4e5ab..4230949d988f 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/nextjs-to-nextjs-vite.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/nextjs-to-nextjs-vite.ts @@ -23,7 +23,10 @@ const transformMainConfig = async (mainConfigPath: string, dryRun: boolean): Pro // Replace @storybook/nextjs with @storybook/nextjs-vite, using a negative lookahead // to avoid corrupting references that are already @storybook/nextjs-vite - const transformedContent = content.replace(/@storybook\/nextjs(?!-vite)/g, '@storybook/nextjs-vite'); + const transformedContent = content.replace( + /@storybook\/nextjs(?!-vite)/g, + '@storybook/nextjs-vite' + ); if (transformedContent !== content && !dryRun) { await writeFile(mainConfigPath, transformedContent); From 6de8c905a0244bbf26b3c52e346a5235b539e339 Mon Sep 17 00:00:00 2001 From: storybook-bot <32066757+storybook-bot@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:33:28 +0000 Subject: [PATCH 23/23] Write changelog for 10.4.0-alpha.5 [skip ci] --- CHANGELOG.prerelease.md | 10 ++++++++++ code/package.json | 3 ++- docs/versions/next.json | 7 +------ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index 45240f7e58a9..c3676b6c576a 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -1,3 +1,13 @@ +## 10.4.0-alpha.5 + +- Addon-a11y: Clear status transition timer on unmount to prevent test flake - [#34203](https://github.com/storybookjs/storybook/pull/34203), thanks @mixelburg! +- Builder-Vite: Use djb2 hash to prevent variable name collisions in builder-vite - [#34274](https://github.com/storybookjs/storybook/pull/34274), thanks @chida09! +- CLI: Fix Next.js Vite automigration corrupting configs already using `@storybook/nextjs-vite` - [#34249](https://github.com/storybookjs/storybook/pull/34249), thanks @nathanjessen! +- Core: Add changeDetection feature flag - [#34314](https://github.com/storybookjs/storybook/pull/34314), thanks @valentinpalkovic! +- Manager: URL-based tag filter state + filter-aware initial story selection - [#34283](https://github.com/storybookjs/storybook/pull/34283), thanks @valentinpalkovic! +- React-Vite: Upgrade @joshwooding/vite-plugin-react-docgen-typescript to 0.7.0 - [#34335](https://github.com/storybookjs/storybook/pull/34335), thanks @beeswhacks! +- Refactor: Extract shared `PseudoStateGrid` component in pseudo-states stories - [#34334](https://github.com/storybookjs/storybook/pull/34334), thanks @copilot-swe-agent! + ## 10.4.0-alpha.4 - Addon-Docs: Add Reset story button to re-render stories in docs - [#34086](https://github.com/storybookjs/storybook/pull/34086), thanks @6810779s! diff --git a/code/package.json b/code/package.json index 9a12f27ad4de..e307f2c38532 100644 --- a/code/package.json +++ b/code/package.json @@ -195,5 +195,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "10.4.0-alpha.5" } diff --git a/docs/versions/next.json b/docs/versions/next.json index 12abd664dec3..e77434eea237 100644 --- a/docs/versions/next.json +++ b/docs/versions/next.json @@ -1,6 +1 @@ -{ - "version": "10.4.0-alpha.4", - "info": { - "plain": "- Addon-Docs: Add Reset story button to re-render stories in docs - [#34086](https://github.com/storybookjs/storybook/pull/34086), thanks @6810779s!\n- Code: Fix inline code blocks inside links removing link affordance - [#33903](https://github.com/storybookjs/storybook/pull/33903), thanks @yatishgoel!\n- Controls: Add maxPresetColors option to ColorControl - [#33998](https://github.com/storybookjs/storybook/pull/33998), thanks @mixelburg!\n- Core: Fix WebSocket connection for StackBlitz/WebContainers - [#34281](https://github.com/storybookjs/storybook/pull/34281), thanks @ghengeveld!\n- Dependencies: Update `vite-plugin-storybook-nextjs` to ^3.2.4 - [#34280](https://github.com/storybookjs/storybook/pull/34280), thanks @k35o!\n- React: Add component metadata extraction via Volar-style LanguageService - [#33914](https://github.com/storybookjs/storybook/pull/33914), thanks @kasperpeulen!\n- StatusValue: Add 'status-value:' - [#34305](https://github.com/storybookjs/storybook/pull/34305), thanks @valentinpalkovic!\n- UI: Ensure Controls panel can scroll horizontally for now - [#34248](https://github.com/storybookjs/storybook/pull/34248), thanks @Sidnioulz!" - } -} +{"version":"10.4.0-alpha.5","info":{"plain":"- Addon-a11y: Clear status transition timer on unmount to prevent test flake - [#34203](https://github.com/storybookjs/storybook/pull/34203), thanks @mixelburg!\n- Builder-Vite: Use djb2 hash to prevent variable name collisions in builder-vite - [#34274](https://github.com/storybookjs/storybook/pull/34274), thanks @chida09!\n- CLI: Fix Next.js Vite automigration corrupting configs already using `@storybook/nextjs-vite` - [#34249](https://github.com/storybookjs/storybook/pull/34249), thanks @nathanjessen!\n- Core: Add changeDetection feature flag - [#34314](https://github.com/storybookjs/storybook/pull/34314), thanks @valentinpalkovic!\n- Manager: URL-based tag filter state + filter-aware initial story selection - [#34283](https://github.com/storybookjs/storybook/pull/34283), thanks @valentinpalkovic!\n- React-Vite: Upgrade @joshwooding/vite-plugin-react-docgen-typescript to 0.7.0 - [#34335](https://github.com/storybookjs/storybook/pull/34335), thanks @beeswhacks!\n- Refactor: Extract shared `PseudoStateGrid` component in pseudo-states stories - [#34334](https://github.com/storybookjs/storybook/pull/34334), thanks @copilot-swe-agent!"}} \ No newline at end of file