From 33aa688b6a15c56be65a8df8e5924dfd41e0ea70 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 7 Oct 2026 15:53:02 +0200 Subject: [PATCH 1/5] Manager: Type index titles by their contents The manager index typed every title by its position, so an unattached MDX page got a synthetic component wrapper that the sidebar hid at render time and search, keyboard navigation and addons did not. Build untyped title nodes from the full index first, then type each node from what sits under it: other titles make a group, any story makes a component, a single docs page takes the title's place, and several docs pages make a group. Filters only prune entries, so index and filteredIndex always agree on the shape. The sidebar's render-time docs hoist and the search-layer workaround from #35495 are no longer needed and are removed. Closes #35513 Co-authored-by: Prajakta Ukirde <165979795+prajaktaukirde@users.noreply.github.com> --- MIGRATION.md | 22 ++ code/core/src/manager-api/lib/stories.test.ts | 90 +++++++ code/core/src/manager-api/lib/stories.ts | 255 +++++++++--------- code/core/src/manager-api/modules/stories.ts | 9 +- code/core/src/manager-api/tests/refs.test.ts | 3 +- .../src/manager-api/tests/stories.test.ts | 82 +++++- .../src/manager/components/sidebar/Search.tsx | 52 +--- .../components/sidebar/Tree.stories.tsx | 30 +-- .../src/manager/components/sidebar/Tree.tsx | 5 - code/core/src/types/modules/api-stories.ts | 2 +- code/e2e-internal/navigation.spec.ts | 6 +- 11 files changed, 341 insertions(+), 215 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index cf890c379a6c..55c5caf12f23 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -19,6 +19,7 @@ - [Yarn PnP support removed](#yarn-pnp-support-removed) - [Top-level `setConfig` layout and UI options removed](#top-level-setconfig-layout-and-ui-options-removed) - [Sidebar label rendering: renderAriaLabel and a context argument](#sidebar-label-rendering-renderarialabel-and-a-context-argument) + - [Manager index: title entries are typed by their contents](#manager-index-title-entries-are-typed-by-their-contents) - [Test runner support ended](#test-runner-support-ended) - [Vitest Addon: requires Vitest 4.0 or higher](#vitest-addon-requires-vitest-40-or-higher) - [Vitest Addon: `setProjectAnnotations` must not be called in setup files](#vitest-addon-setprojectannotations-must-not-be-called-in-setup-files) @@ -917,6 +918,27 @@ option exists in both places, keep the nested value because it was authoritative `sidebar.renderAriaLabel` was added alongside it and must return a plain string; it feeds accessible names for tree entries and the mobile bottom bar's current-page announcement. When `renderLabel` returns a React element, the bottom bar now falls back to the entry name for its concatenated announcement instead of stringifying the element. +### Manager index: title entries are typed by their contents + +The manager index (`useStorybookState().index` and `filteredIndex`) used to type every title by its position: the last segment of a title was always a `component`. +An unattached docs page (for example an MDX file with ``) was therefore wrapped in a synthetic `component` with a single `docs` child, which the sidebar hid when rendering. + +Each title is now typed by everything the story index puts under it: + +| Under the title | Entry in the manager index | +| --- | --- | +| Other titles | `group` (or `root`) | +| At least one story | `component` | +| Exactly one docs page and no stories | the `docs` entry itself, with the title's `name`, `depth` and `parent` (`undefined` at the top level) | +| Several docs pages and no stories | `group` | + +The type comes from the full index, so `index` and `filteredIndex` always agree on it. +Filters only remove entries: a component whose stories are all filtered out stays a `component` with its docs child, and the sidebar shows it as such instead of collapsing it. + +Functions passed to `sidebar.renderLabel` and sidebar filters receive the `docs` entry instead of the old wrapper and its `Docs` child. +Sidebar search lists these pages under their own name, for example `Introduction` instead of `Docs` under `Introduction`. +`api.selectStory('Introduction')` and `linkTo('Introduction')` still resolve to the docs page. + ### Test runner support ended Official support for [`@storybook/test-runner`](https://github.com/storybookjs/test-runner) has ended. The package stays published and accepts Storybook 11 and later as a peer dependency, so existing setups can keep running it at their own risk, but it no longer receives fixes or compatibility updates and prints a warning on every run. diff --git a/code/core/src/manager-api/lib/stories.test.ts b/code/core/src/manager-api/lib/stories.test.ts index a4a2916f368b..2effd6f51fcc 100644 --- a/code/core/src/manager-api/lib/stories.test.ts +++ b/code/core/src/manager-api/lib/stories.test.ts @@ -300,4 +300,94 @@ describe('transformStoryIndexToStoriesHash', () => { expect(result['story-2']).toBeUndefined(); expect(result['2']).toBeUndefined(); }); + + describe('title types', () => { + const options = { + provider: { getConfig: () => ({ sidebar: {} }) } as any, + docsOptions: { docsMode: false }, + filters: {}, + allStatuses: {}, + }; + const docs = (id: string, title: string, name = 'Docs') => ({ + id, + type: 'docs' as const, + title, + name, + importPath: `./${id}.mdx`, + storiesImports: [], + tags: [], + }); + const index: API_PreparedStoryIndex = { + v: 5, + entries: { + 'welcome--docs': docs('welcome--docs', 'Welcome'), + 'guides-intro--docs': docs('guides-intro--docs', 'Guides/Intro'), + 'guides-faq--setup': docs('guides-faq--setup', 'Guides/FAQ', 'Setup'), + 'guides-faq--usage': docs('guides-faq--usage', 'Guides/FAQ', 'Usage'), + 'guides-button--docs': docs('guides-button--docs', 'Guides/Button'), + 'guides-button--primary': { + id: 'guides-button--primary', + type: 'story', + subtype: 'story', + title: 'Guides/Button', + name: 'Primary', + importPath: './button.stories.ts', + tags: [], + }, + }, + }; + const result = transformStoryIndexToStoriesHash(index, options); + + it('puts a single unattached docs page in place of its title', () => { + expect(result['guides-intro']).toBeUndefined(); + expect(result['guides-intro--docs']).toMatchObject({ + name: 'Intro', + parent: 'guides', + depth: 1, + }); + expect(result['guides']).toMatchObject({ + children: ['guides-intro--docs', 'guides-faq', 'guides-button'], + }); + }); + + it('puts a root-level unattached docs page at the root', () => { + expect(result['welcome']).toBeUndefined(); + expect(result['welcome--docs']).toMatchObject({ + name: 'Welcome', + parent: undefined, + depth: 0, + }); + }); + + it('makes a title with several docs pages and no stories a group', () => { + expect(result['guides-faq']).toMatchObject({ + type: 'group', + children: ['guides-faq--setup', 'guides-faq--usage'], + }); + expect(result['guides-faq--setup']).toMatchObject({ name: 'Setup', parent: 'guides-faq' }); + }); + + it('keeps the component of a docs page that has stories', () => { + expect(result['guides-button']).toMatchObject({ + type: 'component', + children: ['guides-button--docs', 'guides-button--primary'], + }); + expect(result['guides-button--docs']).toMatchObject({ + name: 'Docs', + parent: 'guides-button', + }); + }); + + it('keeps the component when filters hide all of its stories', () => { + const filtered = transformStoryIndexToStoriesHash(index, { + ...options, + filters: { hideStories: (entry) => entry.type !== 'story' }, + }); + expect(filtered['guides-button']).toMatchObject({ + type: 'component', + children: ['guides-button--docs'], + }); + expect(filtered['guides-button--primary']).toBeUndefined(); + }); + }); }); diff --git a/code/core/src/manager-api/lib/stories.ts b/code/core/src/manager-api/lib/stories.ts index 0c3cca7d2406..8d2b7a47752f 100644 --- a/code/core/src/manager-api/lib/stories.ts +++ b/code/core/src/manager-api/lib/stories.ts @@ -3,8 +3,8 @@ import type { API_ComponentEntry, API_DocsEntry, API_GroupEntry, - API_HashEntry, API_IndexHash, + API_LeafEntry, API_PreparedIndexEntry, API_PreparedStoryIndex, API_Provider, @@ -29,7 +29,6 @@ import { dedent } from 'ts-dedent'; import { Tag } from '../../shared/constants/tags.ts'; import { type API, type State, combineParameters } from '../root.tsx'; import intersect from './intersect.ts'; -import merge from './merge.ts'; const TITLE_PATH_SEPARATOR = /\s*\/\s*/; @@ -165,6 +164,17 @@ export const transformStoryIndexV4toV5 = ( }; }; +type TitleNodeType = 'root' | 'group' | 'component' | 'docs'; + +type TitleNode = { + id: StoryId; + name: string; + parent: StoryId | undefined; + depth: number; + isRoot: boolean; + children: StoryId[]; +}; + type ToStoriesHashOptions = { provider: API_Provider; docsOptions: DocsOptions; @@ -226,9 +236,20 @@ export const transformStoryIndexToStoriesHash = ( const setShowRoots = typeof showRoots !== 'undefined'; - const storiesHashOutOfOrder = entryValues.reduce((acc, item) => { + const includedIds = new Set(entryValues.map((entry) => entry.id)); + const entriesById = new Map(); + const titleNodes = new Map(); + const testIdsByStoryId = new Map(); + + indexEntries.forEach((item) => { if (docsOptions.docsMode && item.type !== 'docs') { - return acc; + return; + } + entriesById.set(item.id, item); + + if ('parent' in item && item.parent) { + testIdsByStoryId.set(item.parent, [...(testIdsByStoryId.get(item.parent) ?? []), item.id]); + return; } // First, split the title into a set of names, separated by '/' and trimmed. @@ -259,141 +280,124 @@ export const transformStoryIndexToStoriesHash = ( return list; }, [] as string[]); - // Now, let's add an entry to the hash for each path/name pair paths.forEach((id, idx) => { - // The child is the next path, OR the story/docs entry itself - const childId = paths[idx + 1] || item.id; - - if (root.length && idx === 0) { - acc[id] = merge((acc[id] || {}) as API_RootEntry, { - type: 'root', - id, - name: names[idx], - tags: [], - depth: idx, - renderAriaLabel, - renderLabel, - startCollapsed: collapsedRoots.includes(id), - // Note that this will later get appended to the previous list of children (see below) - children: [childId], - }); - // Usually the last path/name pair will be displayed as a component, - // *unless* there are other stories that are more deeply nested under it - // - // For example, if we had stories for both - // - Atoms / Button - // - Atoms / Button / LabelledButton - // - // In this example the entry for 'atoms-button' would *not* be a component. - } else if ((!acc[id] || acc[id].type === 'component') && idx === paths.length - 1) { - acc[id] = merge((acc[id] || {}) as API_ComponentEntry, { - type: 'component', - id, - name: names[idx], - tags: [], - parent: paths[idx - 1], - depth: idx, - renderAriaLabel, - renderLabel, - ...(childId && { - children: [childId], - }), - }); - } else { - acc[id] = merge((acc[id] || {}) as API_GroupEntry, { - type: 'group', - id, - name: names[idx], - tags: [], - parent: paths[idx - 1], - depth: idx, - renderAriaLabel, - renderLabel, - ...(childId && { - children: [childId], - }), - }); + const node = titleNodes.get(id) ?? { + id, + name: '', + parent: paths[idx - 1], + depth: idx, + isRoot: false, + children: [], + }; + titleNodes.set(id, node); + node.name = names[idx]!; + node.isRoot ||= root.length > 0 && idx === 0; + + const childId = paths[idx + 1] ?? item.id; + if (!node.children.includes(childId)) { + node.children.push(childId); } }); + }); + + // A title's type follows from everything indexed under it, never from the active filters + const typeOf = (node: TitleNode): TitleNodeType => { + if (node.isRoot) { + return 'root'; + } + if (node.children.some((id) => titleNodes.has(id))) { + return 'group'; + } + if (node.children.some((id) => entriesById.get(id)!.type === 'story')) { + return 'component'; + } + return node.children.length === 1 ? 'docs' : 'group'; + }; + + const isIncluded = (id: StoryId): boolean => { + const node = titleNodes.get(id); + return node ? node.children.some(isIncluded) : includedIds.has(id); + }; + + const hashIdOf = (id: StoryId) => { + const node = titleNodes.get(id); + return node && typeOf(node) === 'docs' ? node.children[0] : id; + }; - // Finally add an entry for the docs/story/test itself - acc[item.id] = { + const storiesHash: API_IndexHash = {}; + + const addLeaf = (id: StoryId, parent: StoryId | undefined, depth: number) => { + const item = entriesById.get(id)!; + storiesHash[id] = { tags: [], ...item, - depth: paths.length, - parent: 'parent' in item ? item.parent : paths[paths.length - 1], + depth, + parent, renderAriaLabel, renderLabel, prepared: !!item.parameters, } as API_DocsEntry | API_StoryEntry; - return acc; - }, {} as API_IndexHash); - - // This function adds a "root" or "orphan" and all of its descendents to the hash. - function addItem(acc: API_IndexHash, item: API_HashEntry) { - // If we were already inserted as part of a group, that's great. - if (!acc[item.id]) { - acc[item.id] = item; - - // Ensure we add the children depth-first *before* inserting any other entries. - if ('children' in item && item.children) { - item.children.forEach((childId) => addItem(acc, storiesHashOutOfOrder[childId])); - - item.tags = - item.children.reduce((currentTags: Tag[] | null, childId): Tag[] => { - // On the first child, we have nothing to intersect against so we use it as a source of data. - return currentTags === null - ? acc[childId].tags - : intersect(currentTags, acc[childId].tags); - }, null) || []; - } + const testIds = (testIdsByStoryId.get(id) ?? []).filter((testId) => includedIds.has(testId)); + if (testIds.length) { + (storiesHash[id] as API_StoryEntry).children = testIds; + testIds.forEach((testId) => addLeaf(testId, id, depth + 1)); } + }; - if (item.type === 'component') { - const firstChild = acc[item.children[0]]; - if (firstChild && 'importPath' in firstChild) { - // attach importPath to the component node which should be the same for all children - // this way we can add "open in editor" to the component node - item.importPath = firstChild.importPath; - } + const addNode = (node: TitleNode) => { + const type = typeOf(node); + if (type === 'docs') { + addLeaf(node.children[0], node.parent, node.depth); + storiesHash[node.children[0]].name = node.name; + return; } - return acc; - } - // We'll do two passes over the data, adding all the orphans, then all the roots - let storiesHash = Object.values(storiesHashOutOfOrder) - .filter((i) => i.type !== 'root' && !i.parent) - .reduce((acc, item) => addItem(acc, item), {} as API_IndexHash); - - storiesHash = Object.values(storiesHashOutOfOrder) - .filter((i) => i.type === 'root') - .reduce(addItem, storiesHash); - - // Update stories to include tests as children, and increase depth for those tests - storiesHash = Object.values(storiesHash).reduce((acc, item) => { - if (item.type === 'story' && item.subtype === 'test') { - const story = acc[item.parent] as API_StoryEntry; - const component = acc[story.parent] as API_ComponentEntry; - acc[component.id] = { - ...component, - // Remove test from the component node as it will be attached to the story node instead - children: component.children && component.children.filter((id) => id !== item.id), - }; - acc[story.id] = { - ...story, - // Add test to the story node - children: (story.children || []).concat(item.id), - }; - acc[item.id] = { - ...item, - depth: item.depth + 1, - }; - } else { - acc[item.id] = item; + const includedChildren = node.children.filter(isIncluded); + const children = includedChildren.map(hashIdOf); + const entry = { + type, + id: node.id, + name: node.name, + depth: node.depth, + tags: [] as string[], + renderAriaLabel, + renderLabel, + children, + ...(type === 'root' + ? { startCollapsed: collapsedRoots.includes(node.id) } + : { parent: node.parent }), + } as API_RootEntry | API_GroupEntry | API_ComponentEntry; + storiesHash[node.id] = entry; + + includedChildren.forEach((childId) => { + const child = titleNodes.get(childId); + if (child) { + addNode(child); + } else { + addLeaf(childId, node.id, node.depth + 1); + } + }); + + entry.tags = children + .flatMap((id) => { + const child = storiesHash[id]; + return child.type === 'story' && 'children' in child && child.children + ? [id, ...child.children] + : [id]; + }) + .map((id) => storiesHash[id].tags) + .reduce(intersect); + if (entry.type === 'component') { + entry.importPath = (storiesHash[children[0]] as API_LeafEntry).importPath; } - return acc; - }, {} as API_IndexHash); + }; + + const topLevelNodes = [...titleNodes.values()].filter( + (node) => node.depth === 0 && isIncluded(node.id) + ); + topLevelNodes.filter((node) => !node.isRoot).forEach(addNode); + topLevelNodes.filter((node) => node.isRoot).forEach(addNode); return storiesHash; }; @@ -425,6 +429,11 @@ export const getComponentLookupList = memoize(1)((hash: API_IndexHash) => { const value = i[1]; if (value.type === 'component') { acc.push([...value.children]); + } else if ( + value.type === 'docs' && + (!value.parent || hash[value.parent].type !== 'component') + ) { + acc.push([value.id]); } return acc; }, [] as StoryId[][]); diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index 6de34163519b..cd0724f71220 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -651,7 +651,14 @@ export const init: ModuleFn = ({ if (!name) { // Find the entry (group, component, story or docs) that is referred to - const entry = titleOrId ? hash[titleOrId] || hash[sanitize(titleOrId)] : hash[kindSlug]; + const entry = titleOrId + ? hash[titleOrId] || + hash[sanitize(titleOrId)] || + // Unattached docs pages have no entry keyed by their title + Object.values(hash).find( + (e) => e.type === 'docs' && sanitize(e.title) === sanitize(titleOrId) + ) + : hash[kindSlug]; if (!entry) { throw new Error(`Unknown id or title: '${titleOrId}'`); diff --git a/code/core/src/manager-api/tests/refs.test.ts b/code/core/src/manager-api/tests/refs.test.ts index 872b41c7a01b..f85076b60672 100644 --- a/code/core/src/manager-api/tests/refs.test.ts +++ b/code/core/src/manager-api/tests/refs.test.ts @@ -1206,7 +1206,6 @@ describe('Refs API', () => { 'component-a', 'component-a--page', 'component-a--story-2', - 'component-b', 'component-b--page', 'component-c', 'component-c--story-4', @@ -1258,7 +1257,7 @@ describe('Refs API', () => { const hash = refs.fake.index; // We need exact key ordering, even if in theory JS doesn't guarantee it - expect(Object.keys(hash)).toEqual(['component-a', 'component-a--docs']); + expect(Object.keys(hash)).toEqual(['component-a--docs']); expect(hash['component-a--docs'].type).toBe('docs'); }); }); diff --git a/code/core/src/manager-api/tests/stories.test.ts b/code/core/src/manager-api/tests/stories.test.ts index affa9c57098e..7b656df1a13a 100644 --- a/code/core/src/manager-api/tests/stories.test.ts +++ b/code/core/src/manager-api/tests/stories.test.ts @@ -18,7 +18,11 @@ import { UPDATE_STORY_ARGS, } from 'storybook/internal/core-events'; import { logger } from 'storybook/internal/client-logger'; -import { type API_StoryEntry, type StoryIndex } from 'storybook/internal/types'; +import { + type API_PreparedIndexEntry, + type API_StoryEntry, + type StoryIndex, +} from 'storybook/internal/types'; import { global } from '@storybook/global'; @@ -371,36 +375,36 @@ describe('stories API', () => { api.setIndex({ v: 5, entries: { - 'a-sampleone': { + 'a-sampleone--1': { type: 'story', subtype: 'story', - id: 'a-sampleone', + id: 'a-sampleone--1', title: 'A/SampleOne', name: '1', tags: ['shared', 'one-specific'], importPath: './a.ts', }, - 'a-sampletwo': { + 'a-sampletwo--2': { type: 'story', subtype: 'story', - id: 'a-sampletwo', + id: 'a-sampletwo--2', title: 'A/SampleTwo', name: '2', tags: ['shared', 'two-specific'], importPath: './a.ts', }, - 'a-embedded-othertopic': { + 'a-embedded-othertopic--docs': { type: 'docs', - id: 'a-embedded-othertopic', + id: 'a-embedded-othertopic--docs', title: 'A/Embedded/OtherTopic', name: '3', tags: ['shared', 'embedded-docs-specific', 'other'], storiesImports: [], importPath: './embedded/other.mdx', }, - 'a-embedded-extras': { + 'a-embedded-extras--docs': { type: 'docs', - id: 'a-embedded-extras', + id: 'a-embedded-extras--docs', title: 'A/Embedded/Extras', name: '3', tags: ['shared', 'embedded-docs-specific', 'extras'], @@ -414,10 +418,12 @@ describe('stories API', () => { expect(Object.keys(index!)).toEqual([ 'a', 'a-sampleone', + 'a-sampleone--1', 'a-sampletwo', + 'a-sampletwo--2', 'a-embedded', - 'a-embedded-othertopic', - 'a-embedded-extras', + 'a-embedded-othertopic--docs', + 'a-embedded-extras--docs', ]); // Acts as the root, so that the next level is a group we're testing. expect(index!.a).toMatchObject({ @@ -559,7 +565,6 @@ describe('stories API', () => { 'component-a', 'component-a--page', 'component-a--story-2', - 'component-b', 'component-b--docs', 'component-c', 'component-c--story-4', @@ -579,7 +584,7 @@ describe('stories API', () => { const { store } = moduleArgs; api.setIndex({ v: 5, entries: docsEntries }); const { index } = store.getState(); - expect(Object.keys(index!)).toEqual(['component-b', 'component-b--docs']); + expect(Object.keys(index!)).toEqual(['component-b--docs']); }); }); }); @@ -1066,6 +1071,29 @@ describe('stories API', () => { api.jumpToComponent(1); expect(navigate).not.toHaveBeenCalled(); }); + it('lands on an unattached docs page', () => { + const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; + + api.setIndex({ + v: 5, + entries: { + ...navigationEntries, + 'intro--docs': { + type: 'docs', + id: 'intro--docs', + title: 'Intro', + name: 'Page', + importPath: './intro.mdx', + storiesImports: [], + }, + }, + }); + api.jumpToComponent(1); + expect(navigate).toHaveBeenCalledWith('/docs/intro--docs', undefined); + }); it('does nothing if you are at the first component and go backward', () => { const initialState = { path: '/story/a--2', storyId: 'a--2', viewMode: 'story' }; const moduleArgs = createMockModuleArgs({ initialState }); @@ -1121,6 +1149,34 @@ describe('stories API', () => { api.selectStory('a--1'); expect(store.getState().settings.lastTrackedStoryId).toBe('a--1'); }); + it('selects the docs page of a component whose stories are all filtered out', () => { + const initialState = { + path: '/story/a--1', + storyId: 'a--1', + viewMode: 'story', + filters: { hideStories: (entry: API_PreparedIndexEntry) => entry.type !== 'story' }, + }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; + + api.setIndex({ + v: 5, + entries: { + 'a--docs': { + type: 'docs', + id: 'a--docs', + title: 'a', + name: 'Docs', + importPath: './a.ts', + storiesImports: [], + }, + 'a--1': navigationEntries['a--1'], + }, + }); + api.selectStory('a'); + expect(navigate).toHaveBeenCalledWith('/docs/a--docs', undefined); + }); it('selects first visible child when component is clicked with filtered index', () => { const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; const moduleArgs = createMockModuleArgs({ initialState }); diff --git a/code/core/src/manager/components/sidebar/Search.tsx b/code/core/src/manager/components/sidebar/Search.tsx index 17254ca94814..b60b04c4a2e3 100644 --- a/code/core/src/manager/components/sidebar/Search.tsx +++ b/code/core/src/manager/components/sidebar/Search.tsx @@ -252,55 +252,15 @@ export const Search = React.memo(function Search({ let results: DownshiftItem[] = []; const resultIds: Set = new Set(); - const allMatches = (fuse.search(input) as SearchResult[]).filter(({ item }) => { - return item.type === 'component' || item.type === 'docs' || item.type === 'story'; - }); - - // When the index is being created, we have a legacy piece of logic that - // wraps every docs page inside a component entry. This originates from - // Storybook 6 and has never been removed. Because of it, we must dedupe - // docs entries that are hidden under a fake component entry. - // See https://github.com/storybookjs/storybook/issues/35513 for details. - const docsParentIds = new Set(); - allMatches.forEach(({ item }) => { - if (item.type === 'docs' && item.parent) { - docsParentIds.add(item.parent); - } - }); - - // Components suppressed in favor of a matching docs child that hasn't been rendered yet. - // The suppressed component still occupies its slot in `resultIds` so that sibling stories - // ranked between the component and its docs entry are deduplicated, as they were before. - const pendingDocsReplacements = new Set(); - - const distinctResults = allMatches.filter(({ item }) => { - // This always gets called before the corresponding docs item - // because of the sorting performed by the search index. So it's - // safe to use `pendingDocsReplacements` in a single-pass lookup. - if (item.type === 'component' && docsParentIds.has(item.id)) { - if (!resultIds.has(item.id)) { - resultIds.add(item.id); - pendingDocsReplacements.add(item.id); - } - return false; - } - - // When we reach this, we know we found an unattached MDX page with - // a synthetic docs wrapper. Like in Tree.tsx, remove the wrapper - // and present the docs item to end users. - if (item.type === 'docs' && item.parent && pendingDocsReplacements.has(item.parent)) { - pendingDocsReplacements.delete(item.parent); - resultIds.add(item.id); - return true; - } - // @ts-expect-error (non strict) - if (resultIds.has(item.parent)) { + const distinctResults = (fuse.search(input) as SearchResult[]).filter(({ item }) => { + if ( + !(item.type === 'component' || item.type === 'docs' || item.type === 'story') || + // @ts-expect-error (non strict) + resultIds.has(item.parent) + ) { return false; } resultIds.add(item.id); - if (item.type === 'docs' && item.parent) { - resultIds.add(item.parent); - } return true; }); diff --git a/code/core/src/manager/components/sidebar/Tree.stories.tsx b/code/core/src/manager/components/sidebar/Tree.stories.tsx index 220144221dbd..4d83ce127a24 100644 --- a/code/core/src/manager/components/sidebar/Tree.stories.tsx +++ b/code/core/src/manager/components/sidebar/Tree.stories.tsx @@ -202,28 +202,16 @@ export const DocsOnlySingleStoryComponents = { isBrowsing isMain refId={DEFAULT_REF_ID} - // @ts-expect-error (non strict) data={{ - ...{ - single: { - type: 'component', - name: 'Single', - id: 'single', - parent: null, - depth: 0, - children: ['single--docs'], - }, - 'single--docs': { - type: 'docs', - id: 'single--docs', - title: 'Single', - name: 'Single', - tags: [], - prepared: true, - depth: 1, - parent: 'single', - importPath: './single.stories.js', - }, + 'single--docs': { + type: 'docs', + id: 'single--docs', + title: 'Single', + name: 'Single', + tags: [], + prepared: true, + depth: 0, + importPath: './single.stories.js', }, ...Object.keys(index).reduce((acc, key) => { if (key === 'tooltip-tooltipselect--default') { diff --git a/code/core/src/manager/components/sidebar/Tree.tsx b/code/core/src/manager/components/sidebar/Tree.tsx index 7dc7fd540cb3..2dfe0eab7481 100644 --- a/code/core/src/manager/components/sidebar/Tree.tsx +++ b/code/core/src/manager/components/sidebar/Tree.tsx @@ -669,7 +669,6 @@ export const Tree = React.memo<{ // Create a list of component IDs which should be collapsed into their (only) child. // That is: // - components with a single story child with the same name - // - components with only a single docs child const singleStoryComponentIds = useMemo(() => { return Object.keys(data).filter((id) => { const entry = data[id]; @@ -686,10 +685,6 @@ export const Tree = React.memo<{ const onlyChild = data[children[0]]; - if (onlyChild.type === 'docs') { - return true; - } - if (onlyChild.type === 'story' && onlyChild.subtype === 'story') { return isStoryHoistable(onlyChild.name, name); } diff --git a/code/core/src/types/modules/api-stories.ts b/code/core/src/types/modules/api-stories.ts index b06bc817bbc6..70e9fede573a 100644 --- a/code/core/src/types/modules/api-stories.ts +++ b/code/core/src/types/modules/api-stories.ts @@ -34,7 +34,7 @@ export interface API_ComponentEntry extends API_BaseEntry { export interface API_DocsEntry extends API_BaseEntry { type: 'docs'; - parent: StoryId; + parent?: StoryId; title: ComponentTitle; importPath: Path; prepared: boolean; diff --git a/code/e2e-internal/navigation.spec.ts b/code/e2e-internal/navigation.spec.ts index be71894f5b95..9e2e9bf6945f 100644 --- a/code/e2e-internal/navigation.spec.ts +++ b/code/e2e-internal/navigation.spec.ts @@ -40,13 +40,13 @@ test.describe('navigating', () => { await page.getByRole('searchbox').fill('Monospace'); const searchItem = page.getByRole('option', { - name: 'Docs / Monospace brand / Typography', + name: 'Typography / Monospace brand', exact: true, }); await expect(searchItem).toBeVisible(); await expect( page.getByRole('option', { - name: 'Docs / Sans-serif brand / Typography', + name: 'Typography / Sans-serif brand', exact: true, }) ).toBeHidden(); @@ -64,7 +64,7 @@ test.describe('navigating', () => { await page .getByRole('option', { - name: 'Docs / Monospace brand / Typography', + name: 'Typography / Monospace brand', exact: true, }) .click(); From 3e44dd3107d5c9c948d31d24961d57da5807d3a8 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 7 Oct 2026 16:53:34 +0200 Subject: [PATCH 2/5] Manager: Redirect title-id deep links of unattached docs pages A URL such as ?path=/docs/brand-typography used to resolve through the component wrapper. Without the wrapper the id is not in the index, so the STORY_SPECIFIED handler treated it as a known leaf, pushed it back to the preview and showed "Couldn't find story matching id". Only a story or docs entry counts as the current selection now, so the manager follows the entry the preview resolved from the partial id. --- code/core/src/manager-api/modules/stories.ts | 2 +- code/core/src/manager-api/tests/stories.test.ts | 15 +++++++++++++++ code/e2e-internal/navigation.spec.ts | 14 ++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index cd0724f71220..8b360b8cbc04 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -1128,7 +1128,7 @@ export const init: ModuleFn = ({ const stateHasSelection = state.viewMode && state.storyId; const stateSelectionDifferent = state.viewMode !== viewMode || state.storyId !== storyId; const { type } = state.index?.[state.storyId] || {}; - const isStory = !(type === 'root' || type === 'component' || type === 'group'); + const isStory = type === 'story' || type === 'docs'; /** * When storybook starts, we want to navigate to the first story. But there are a few diff --git a/code/core/src/manager-api/tests/stories.test.ts b/code/core/src/manager-api/tests/stories.test.ts index 7b656df1a13a..dd04aa3d7fe1 100644 --- a/code/core/src/manager-api/tests/stories.test.ts +++ b/code/core/src/manager-api/tests/stories.test.ts @@ -741,6 +741,21 @@ describe('stories API', () => { provider.channel.emit(STORY_SPECIFIED, { storyId: 'a--1', viewMode: 'story' }); expect(navigate).not.toHaveBeenCalled(); }); + it('navigates to the entry the preview resolved from a partial id', async () => { + const moduleArgs = createMockModuleArgs({ + initialState: { + path: '/docs/intro', + storyId: 'intro', + viewMode: 'docs', + index: { 'intro--docs': { type: 'docs', id: 'intro--docs' } as any }, + }, + }); + initStories(moduleArgs as unknown as ModuleArgs); + const { navigate, provider } = moduleArgs; + + provider.channel.emit(STORY_SPECIFIED, { storyId: 'intro--docs', viewMode: 'docs' }); + expect(navigate).toHaveBeenCalledWith('/docs/intro--docs', undefined); + }); it('DOES not navigate if a settings page was selected', async () => { const moduleArgs = createMockModuleArgs({ initialState: { path: '/settings/about', index: {} }, diff --git a/code/e2e-internal/navigation.spec.ts b/code/e2e-internal/navigation.spec.ts index 9e2e9bf6945f..d4182aad69c4 100644 --- a/code/e2e-internal/navigation.spec.ts +++ b/code/e2e-internal/navigation.spec.ts @@ -21,6 +21,20 @@ test.describe('navigating', () => { expect(sbPage.page.url()).toContain('/docs/example-button--docs'); }); + test('a URL with the title id of an unattached docs page redirects to the page', async ({ + page, + }) => { + await page.goto(`${storybookUrl}?path=/docs/brand-typography`); + + const sbPage = new SbPage(page, expect); + await sbPage.waitUntilLoaded(); + + await page.waitForFunction(() => + window.document.location.href.match('/docs/brand-typography--docs') + ); + await expect(sbPage.previewIframe().getByRole('heading', { name: 'Monospace' })).toBeVisible(); + }); + test('searching for "typography" surfaces the brand typography docs entry', async ({ page }) => { await page.goto(storybookUrl); From 029d0b6a1ca4621aa6a55263bbf01ede70c21478 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 13 Oct 2026 15:25:12 +0200 Subject: [PATCH 3/5] Manager: Rename jumpToComponent to jumpToSubtree The jump also stops on docs pages without a component, so the name and the menu and shortcut labels now say so. The shortcut keys keep their names so customized bindings carry over. --- MIGRATION.md | 4 ++++ code/core/src/manager-api/modules/shortcuts.ts | 4 ++-- code/core/src/manager-api/modules/stories.ts | 11 ++++++----- code/core/src/manager-api/tests/stories.test.ts | 12 ++++++------ .../src/manager/components/sidebar/Menu.stories.tsx | 2 +- code/core/src/manager/container/Menu.tsx | 8 ++++---- code/core/src/manager/settings/shortcuts.tsx | 4 ++-- 7 files changed, 25 insertions(+), 20 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 55c5caf12f23..8a0e46c305e9 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -939,6 +939,10 @@ Functions passed to `sidebar.renderLabel` and sidebar filters receive the `docs` Sidebar search lists these pages under their own name, for example `Introduction` instead of `Docs` under `Introduction`. `api.selectStory('Introduction')` and `linkTo('Introduction')` still resolve to the docs page. +`api.jumpToComponent` is renamed to `api.jumpToSubtree`, because it now also stops on docs pages that are not attached to a component. +Its menu entries and shortcut labels read `Previous component or page` and `Next component or page`. +The shortcut keys `prevComponent` and `nextComponent` keep their names, so customized shortcuts carry over. + ### Test runner support ended Official support for [`@storybook/test-runner`](https://github.com/storybookjs/test-runner) has ended. The package stays published and accepts Storybook 11 and later as a peer dependency, so existing setups can keep running it at their own risk, but it no longer receives fixes or compatibility updates and prints a warning on every run. diff --git a/code/core/src/manager-api/modules/shortcuts.ts b/code/core/src/manager-api/modules/shortcuts.ts index 26aab9162d61..4a1f4d04f4e8 100644 --- a/code/core/src/manager-api/modules/shortcuts.ts +++ b/code/core/src/manager-api/modules/shortcuts.ts @@ -355,12 +355,12 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => { } case 'nextComponent': { - fullAPI.jumpToComponent(1); + fullAPI.jumpToSubtree(1); break; } case 'prevComponent': { - fullAPI.jumpToComponent(-1); + fullAPI.jumpToSubtree(-1); break; } diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index 8b360b8cbc04..2d9ff2cc9119 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -169,13 +169,14 @@ export interface SubAPI { setIndex: (index: API_PreparedStoryIndex) => Promise; /** - * Jumps to the next or previous component in the index. + * Jumps to the first entry of the next or previous component, or to the next or previous docs + * page that is not attached to a component. * - * @param {Direction} direction - The direction to jump. Use -1 to jump to the previous component, - * and 1 to jump to the next component. + * @param {Direction} direction - The direction to jump. Use -1 to jump backwards and 1 to jump + * forwards. * @returns {void} */ - jumpToComponent: (direction: Direction) => void; + jumpToSubtree: (direction: Direction) => void; /** * Jumps to the next or previous story in the story index. * @@ -557,7 +558,7 @@ export const init: ModuleFn = ({ // so this strange logic needs to be here until various client code is updated. return parameters || undefined; }, - jumpToComponent: (direction) => { + jumpToSubtree: (direction) => { const { filteredIndex, storyId, refs, refId } = store.getState(); const story = api.getData(storyId, refId); diff --git a/code/core/src/manager-api/tests/stories.test.ts b/code/core/src/manager-api/tests/stories.test.ts index dd04aa3d7fe1..04589b463450 100644 --- a/code/core/src/manager-api/tests/stories.test.ts +++ b/code/core/src/manager-api/tests/stories.test.ts @@ -1047,7 +1047,7 @@ describe('stories API', () => { expect(result).toEqual(['component-a--story-1', 'component-a--story-2']); }); }); - describe('jumpToComponent', () => { + describe('jumpToSubtree', () => { it('works forward', () => { const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; const moduleArgs = createMockModuleArgs({ initialState }); @@ -1055,7 +1055,7 @@ describe('stories API', () => { const { navigate } = moduleArgs; api.setIndex({ v: 5, entries: navigationEntries }); - api.jumpToComponent(1); + api.jumpToSubtree(1); expect(navigate).toHaveBeenCalledWith('/story/b-c--1', undefined); }); it('works backwards', () => { @@ -1069,7 +1069,7 @@ describe('stories API', () => { const { navigate } = moduleArgs; api.setIndex({ v: 5, entries: navigationEntries }); - api.jumpToComponent(-1); + api.jumpToSubtree(-1); expect(navigate).toHaveBeenCalledWith('/story/a--1', undefined); }); it('does nothing if you are in the last component and go forward', () => { @@ -1083,7 +1083,7 @@ describe('stories API', () => { const { navigate } = moduleArgs; api.setIndex({ v: 5, entries: navigationEntries }); - api.jumpToComponent(1); + api.jumpToSubtree(1); expect(navigate).not.toHaveBeenCalled(); }); it('lands on an unattached docs page', () => { @@ -1106,7 +1106,7 @@ describe('stories API', () => { }, }, }); - api.jumpToComponent(1); + api.jumpToSubtree(1); expect(navigate).toHaveBeenCalledWith('/docs/intro--docs', undefined); }); it('does nothing if you are at the first component and go backward', () => { @@ -1116,7 +1116,7 @@ describe('stories API', () => { const { navigate } = moduleArgs; api.setIndex({ v: 5, entries: navigationEntries }); - api.jumpToComponent(-1); + api.jumpToSubtree(-1); expect(navigate).not.toHaveBeenCalled(); }); }); diff --git a/code/core/src/manager/components/sidebar/Menu.stories.tsx b/code/core/src/manager/components/sidebar/Menu.stories.tsx index 802db4dacadc..774cf8f61e9a 100644 --- a/code/core/src/manager/components/sidebar/Menu.stories.tsx +++ b/code/core/src/manager/components/sidebar/Menu.stories.tsx @@ -35,7 +35,7 @@ const getAPIMocks = () => ({ toggleNav: fn().mockName('api::toggleNav'), toggleToolbar: fn().mockName('api::toggleToolbar'), togglePanel: fn().mockName('api::togglePanel'), - jumpToComponent: fn().mockName('api::jumpToComponent'), + jumpToSubtree: fn().mockName('api::jumpToSubtree'), jumpToStory: fn().mockName('api::jumpToStory'), emit: fn().mockName('api::emit'), }); diff --git a/code/core/src/manager/container/Menu.tsx b/code/core/src/manager/container/Menu.tsx index ed647eb8920a..fca6b41b36c9 100644 --- a/code/core/src/manager/container/Menu.tsx +++ b/code/core/src/manager/container/Menu.tsx @@ -131,8 +131,8 @@ export const useMenu = ({ const up = useMemo( () => ({ id: 'up', - title: 'Previous component', - onClick: () => api.jumpToComponent(-1), + title: 'Previous component or page', + onClick: () => api.jumpToSubtree(-1), right: enableShortcuts ? : null, icon: <>, }), @@ -142,8 +142,8 @@ export const useMenu = ({ const down = useMemo( () => ({ id: 'down', - title: 'Next component', - onClick: () => api.jumpToComponent(1), + title: 'Next component or page', + onClick: () => api.jumpToSubtree(1), right: enableShortcuts ? : null, icon: <>, }), diff --git a/code/core/src/manager/settings/shortcuts.tsx b/code/core/src/manager/settings/shortcuts.tsx index 150339d30e2c..e024010c5a6c 100644 --- a/code/core/src/manager/settings/shortcuts.tsx +++ b/code/core/src/manager/settings/shortcuts.tsx @@ -124,8 +124,8 @@ const shortcutLabels = { focusNav: 'Focus sidebar', focusIframe: 'Focus canvas', focusPanel: 'Focus addons', - prevComponent: 'Previous component', - nextComponent: 'Next component', + prevComponent: 'Previous component or page', + nextComponent: 'Next component or page', prevStory: 'Previous story', nextStory: 'Next story', shortcutsPage: 'Go to shortcuts page', From 2f13bc190587174bb9baa481064a3ca43ec261bc Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Mon, 19 Oct 2026 15:41:52 +0200 Subject: [PATCH 4/5] Sidebar: Keep showing docs-only components as a single docs page A component whose stories are all hidden, for example with autodocs and '!dev' on the meta, is left with only its docs entry after filtering. The sidebar showed it as one docs page before the index change; restore that render-time hoist so the documented docs-only pattern looks the same. --- MIGRATION.md | 3 +- .../components/sidebar/Tree.stories.tsx | 44 +++++++++++++++++++ .../src/manager/components/sidebar/Tree.tsx | 5 +++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/MIGRATION.md b/MIGRATION.md index 8a0e46c305e9..1749696b7556 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -933,7 +933,8 @@ Each title is now typed by everything the story index puts under it: | Several docs pages and no stories | `group` | The type comes from the full index, so `index` and `filteredIndex` always agree on it. -Filters only remove entries: a component whose stories are all filtered out stays a `component` with its docs child, and the sidebar shows it as such instead of collapsing it. +Filters only remove entries: a component whose stories are all filtered out stays a `component` with its docs child in `filteredIndex`. +The sidebar still shows such a component as a single docs page, for example for [docs-only stories](https://storybook.js.org/docs/writing-stories/tags#docs-only-stories). Functions passed to `sidebar.renderLabel` and sidebar filters receive the `docs` entry instead of the old wrapper and its `Docs` child. Sidebar search lists these pages under their own name, for example `Introduction` instead of `Docs` under `Introduction`. diff --git a/code/core/src/manager/components/sidebar/Tree.stories.tsx b/code/core/src/manager/components/sidebar/Tree.stories.tsx index 4d83ce127a24..84e4d0d53a36 100644 --- a/code/core/src/manager/components/sidebar/Tree.stories.tsx +++ b/code/core/src/manager/components/sidebar/Tree.stories.tsx @@ -244,6 +244,50 @@ export const DocsOnlySingleStoryComponents = { }, }; +/** A component whose stories are all hidden (`!dev`) shows as a single docs page. */ +export const ComponentWithOnlyDocsLeft: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const docsItem = canvasElement.querySelector('[data-item-id="docs-only--docs"]'); + + await expect(docsItem).toHaveAttribute('data-nodetype', 'document'); + await expect(docsItem).toHaveTextContent('Docs Only'); + await expect(canvasElement.querySelector('[data-item-id="docs-only"]')).toBeNull(); + }, +}; + // SkipToCanvas Link only shows on desktop widths export const SkipToCanvasLinkFocused: Story = { ...DocsOnlySingleStoryComponents, diff --git a/code/core/src/manager/components/sidebar/Tree.tsx b/code/core/src/manager/components/sidebar/Tree.tsx index 2dfe0eab7481..247841fe44c5 100644 --- a/code/core/src/manager/components/sidebar/Tree.tsx +++ b/code/core/src/manager/components/sidebar/Tree.tsx @@ -669,6 +669,7 @@ export const Tree = React.memo<{ // Create a list of component IDs which should be collapsed into their (only) child. // That is: // - components with a single story child with the same name + // - components left with only a docs child after filtering (e.g. docs-only `!dev` stories) const singleStoryComponentIds = useMemo(() => { return Object.keys(data).filter((id) => { const entry = data[id]; @@ -685,6 +686,10 @@ export const Tree = React.memo<{ const onlyChild = data[children[0]]; + if (onlyChild.type === 'docs') { + return true; + } + if (onlyChild.type === 'story' && onlyChild.subtype === 'story') { return isStoryHoistable(onlyChild.name, name); } From 525bc13cf3cc93bdbe7c9b0260cfdcb3f62fb0d0 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Mon, 19 Oct 2026 15:53:00 +0200 Subject: [PATCH 5/5] Manager: Address review naming feedback Rename the leaf check in the STORY_SPECIFIED handler to isLeaf, and use the established 'unattached docs page' term in the jumpToSubtree JSDoc. --- code/core/src/manager-api/modules/stories.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index f091bf1470ad..e7245ba0dbc5 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -194,8 +194,8 @@ export interface SubAPI { setIndex: (index: API_PreparedStoryIndex) => Promise; /** - * Jumps to the first entry of the next or previous component, or to the next or previous docs - * page that is not attached to a component. + * Jumps to the first entry of the next or previous component, or to the next or previous + * unattached docs page. * * @param {Direction} direction - The direction to jump. Use -1 to jump backwards and 1 to jump * forwards. @@ -1147,7 +1147,7 @@ export const init: ModuleFn = ({ const stateHasSelection = state.viewMode && state.storyId; const stateSelectionDifferent = state.viewMode !== viewMode || state.storyId !== storyId; const { type } = state.index?.[state.storyId] || {}; - const isStory = type === 'story' || type === 'docs'; + const isLeaf = type === 'story' || type === 'docs'; /** * When storybook starts, we want to navigate to the first story. But there are a few @@ -1180,7 +1180,7 @@ export const init: ModuleFn = ({ } } - if (stateHasSelection && stateSelectionDifferent && isStory) { + if (stateHasSelection && stateSelectionDifferent && isLeaf) { // The manager state is correct, the preview state is lagging behind provider.channel?.emit(SET_CURRENT_STORY, { storyId: state.storyId,