diff --git a/code/lib/api/src/modules/channel.ts b/code/lib/api/src/modules/channel.ts index f526302b4df2..d2213348b73b 100644 --- a/code/lib/api/src/modules/channel.ts +++ b/code/lib/api/src/modules/channel.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-param-reassign */ import { STORIES_COLLAPSE_ALL, STORIES_EXPAND_ALL } from '@storybook/core-events'; import { Channel } from '@storybook/channels'; import type { Listener } from '@storybook/channels'; @@ -26,7 +27,19 @@ export const init: ModuleFn = ({ provider }) => { }, off: (type, cb) => provider.channel.removeListener(type, cb), once: (type, cb) => provider.channel.once(type, cb), - emit: (type, ...args) => provider.channel.emit(type, ...args), + emit: (type, data, ...args) => { + if ( + data?.options?.target && + data.options.target !== 'storybook-preview-iframe' && + !data.options.target.startsWith('storybook-ref-') + ) { + data.options.target = + data.options.target !== 'storybook_internal' + ? `storybook-ref-${data.options.target}` + : 'storybook-preview-iframe'; + } + provider.channel.emit(type, data, ...args); + }, collapseAll: () => { provider.channel.emit(STORIES_COLLAPSE_ALL, {}); diff --git a/code/lib/api/src/modules/stories.ts b/code/lib/api/src/modules/stories.ts index 6a9f9f1813db..ea4215ebcaec 100644 --- a/code/lib/api/src/modules/stories.ts +++ b/code/lib/api/src/modules/stories.ts @@ -1,7 +1,7 @@ import global from 'global'; import { toId, sanitize } from '@storybook/csf'; import { - PRELOAD_STORIES, + PRELOAD_ENTRIES, STORY_PREPARED, UPDATE_STORY_ARGS, RESET_STORY_ARGS, @@ -320,9 +320,7 @@ export const init: ModuleFn = ({ fullAPI.emit(UPDATE_STORY_ARGS, { storyId, updatedArgs, - options: { - target: refId ? `storybook-ref-${refId}` : 'storybook-preview-iframe', - }, + options: { target: refId }, }); }, resetStoryArgs: (story, argNames?: [string]) => { @@ -330,9 +328,7 @@ export const init: ModuleFn = ({ fullAPI.emit(RESET_STORY_ARGS, { storyId, argNames, - options: { - target: refId ? `storybook-ref-${refId}` : 'storybook-preview-iframe', - }, + options: { target: refId }, }); }, fetchStoryList: async () => { @@ -448,7 +444,7 @@ export const init: ModuleFn = ({ } if (sourceType === 'local') { - const { storyId, storiesHash } = store.getState(); + const { storyId, storiesHash, refId } = store.getState(); // create a list of related stories to be preloaded const toBePreloaded = Array.from( @@ -458,7 +454,10 @@ export const init: ModuleFn = ({ ]) ).filter(Boolean); - fullAPI.emit(PRELOAD_STORIES, toBePreloaded); + fullAPI.emit(PRELOAD_ENTRIES, { + ids: toBePreloaded, + options: { target: refId }, + }); } }); diff --git a/code/lib/api/src/tests/stories.test.ts b/code/lib/api/src/tests/stories.test.ts index cffa81cbaac8..132a95733526 100644 --- a/code/lib/api/src/tests/stories.test.ts +++ b/code/lib/api/src/tests/stories.test.ts @@ -623,7 +623,7 @@ describe('stories API', () => { storyId: 'a--1', updatedArgs: { foo: 'bar' }, options: { - target: 'storybook-preview-iframe', + target: undefined, }, }); @@ -654,12 +654,12 @@ describe('stories API', () => { storyId: 'a--1', updatedArgs: { foo: 'bar' }, options: { - target: 'storybook-ref-refId', + target: 'refId', }, }); }); - it('resetStoryArgs emits RESET_STORY_ARGS to the local frame and does not change anything', () => { + it('refId to the local frame and does not change anything', () => { const navigate = jest.fn(); const emit = jest.fn(); const on = jest.fn(); @@ -681,7 +681,7 @@ describe('stories API', () => { storyId: 'a--1', argNames: ['foo'], options: { - target: 'storybook-preview-iframe', + target: undefined, }, }); @@ -712,7 +712,7 @@ describe('stories API', () => { storyId: 'a--1', argNames: ['foo'], options: { - target: 'storybook-ref-refId', + target: 'refId', }, }); }); diff --git a/code/lib/core-events/src/index.ts b/code/lib/core-events/src/index.ts index 291eb9050601..0fb8d7bed579 100644 --- a/code/lib/core-events/src/index.ts +++ b/code/lib/core-events/src/index.ts @@ -19,7 +19,7 @@ enum events { // Force the current story to re-render from scratch, with its initial args FORCE_REMOUNT = 'forceRemount', // Request the story has been loaded into the store, ahead of time, before it's actually - PRELOAD_STORIES = 'preloadStories', + PRELOAD_ENTRIES = 'preloadStories', // The story has been loaded into the store, we have parameters/args/etc STORY_PREPARED = 'storyPrepared', // The next 6 events are emitted by the StoryRenderer when rendering the current story @@ -78,7 +78,7 @@ export const { STORY_PREPARED, STORY_CHANGED, STORY_UNCHANGED, - PRELOAD_STORIES, + PRELOAD_ENTRIES, STORY_RENDERED, STORY_MISSING, STORY_ERRORED, diff --git a/code/lib/preview-web/src/PreviewWeb.test.ts b/code/lib/preview-web/src/PreviewWeb.test.ts index 674a938cffe7..940bd56acfbb 100644 --- a/code/lib/preview-web/src/PreviewWeb.test.ts +++ b/code/lib/preview-web/src/PreviewWeb.test.ts @@ -1104,7 +1104,7 @@ describe('PreviewWeb', () => { await waitForRender(); importFn.mockClear(); - await preview.onPreloadStories(['component-two--c']); + await preview.onPreloadStories({ ids: ['component-two--c'] }); expect(importFn).toHaveBeenCalledWith('./src/ComponentTwo.stories.js'); }); @@ -1114,7 +1114,7 @@ describe('PreviewWeb', () => { await waitForRender(); importFn.mockClear(); - await preview.onPreloadStories(['component-one--docs']); + await preview.onPreloadStories({ ids: ['component-one--docs'] }); expect(importFn).toHaveBeenCalledWith('./src/ComponentOne.stories.js'); }); @@ -1124,7 +1124,7 @@ describe('PreviewWeb', () => { await waitForRender(); importFn.mockClear(); - await preview.onPreloadStories(['introduction--docs']); + await preview.onPreloadStories({ ids: ['introduction--docs'] }); expect(importFn).toHaveBeenCalledWith('./src/Introduction.mdx'); }); it('loads imports of modern docs entries', async () => { @@ -1133,7 +1133,7 @@ describe('PreviewWeb', () => { await waitForRender(); importFn.mockClear(); - await preview.onPreloadStories(['introduction--docs']); + await preview.onPreloadStories({ ids: ['introduction--docs'] }); expect(importFn).toHaveBeenCalledWith('./src/ComponentTwo.stories.js'); }); }); diff --git a/code/lib/preview-web/src/PreviewWeb.tsx b/code/lib/preview-web/src/PreviewWeb.tsx index 101e26f96c6c..6b6014236f9a 100644 --- a/code/lib/preview-web/src/PreviewWeb.tsx +++ b/code/lib/preview-web/src/PreviewWeb.tsx @@ -4,7 +4,7 @@ import global from 'global'; import { CURRENT_STORY_WAS_SET, IGNORED_EXCEPTION, - PRELOAD_STORIES, + PRELOAD_ENTRIES, PREVIEW_KEYDOWN, SET_CURRENT_STORY, SET_STORIES, @@ -93,7 +93,7 @@ export class PreviewWeb extends Preview) { @@ -240,8 +240,14 @@ export class PreviewWeb extends Preview this.storyStore.loadEntry(id))); + async onPreloadStories({ ids }: { ids: string[] }) { + /** + * It's possible that we're trying to preload a story in a ref we haven't loaded the iframe for yet. + * Because of the way the targeting works, if we can't find the targeted iframe, + * we'll use the currently active iframe which can cause the event to be targeted + * to the wrong iframe, causing an error if the storyId does not exists there. + */ + await Promise.allSettled(ids.map((id) => this.storyStore.loadEntry(id))); } // RENDERING diff --git a/code/lib/ui/src/components/preview/preview.tsx b/code/lib/ui/src/components/preview/preview.tsx index d6ab8d99081f..67c9c2f2f8c4 100644 --- a/code/lib/ui/src/components/preview/preview.tsx +++ b/code/lib/ui/src/components/preview/preview.tsx @@ -164,9 +164,7 @@ const Preview = React.memo((props) => { api.emit(SET_CURRENT_STORY, { storyId: id, viewMode, - options: { - target: refId ? `storybook-ref-${refId}` : 'storybook-preview-iframe', - }, + options: { target: refId }, }); } } diff --git a/code/lib/ui/src/components/sidebar/SearchResults.tsx b/code/lib/ui/src/components/sidebar/SearchResults.tsx index eb1c7cbb9832..bc8dcf1b9164 100644 --- a/code/lib/ui/src/components/sidebar/SearchResults.tsx +++ b/code/lib/ui/src/components/sidebar/SearchResults.tsx @@ -4,6 +4,8 @@ import global from 'global'; import React, { FC, MouseEventHandler, ReactNode, useCallback, useEffect } from 'react'; import { ControllerStateAndHelpers } from 'downshift'; +import { useStorybookApi } from '@storybook/api'; +import { PRELOAD_ENTRIES } from '@storybook/core-events'; import { ComponentNode, DocumentNode, Path, RootNode, StoryNode } from './TreeNode'; import { Match, @@ -121,6 +123,17 @@ const Result: FC< [onClick] ); + const api = useStorybookApi(); + useEffect(() => { + if (api && props.isHighlighted && item.isComponent) { + api.emit( + PRELOAD_ENTRIES, + { ids: [item.isLeaf ? item.id : item.children[0]] }, + { options: { target: item.refId } } + ); + } + }, [props.isHighlighted, item]); + const nameMatch = matches.find((match: Match) => match.key === 'name'); const pathMatches = matches.filter((match: Match) => match.key === 'path'); const label = ( @@ -175,6 +188,7 @@ export const SearchResults: FC<{ isLoading = false, enableShortcuts = true, }) => { + const api = useStorybookApi(); useEffect(() => { const handleEscape = (event: KeyboardEvent) => { if (!enableShortcuts || isLoading || event.repeat) return; @@ -190,6 +204,20 @@ export const SearchResults: FC<{ return () => document.removeEventListener('keydown', handleEscape); }, [enableShortcuts, isLoading]); + const mouseOverHandler = useCallback((event: MouseEvent) => { + const currentTarget = event.currentTarget as HTMLElement; + const storyId = currentTarget.getAttribute('data-id'); + const refId = currentTarget.getAttribute('data-refid'); + const item = api.getData(storyId, refId === 'storybook_internal' ? undefined : refId); + + if (item.isComponent) { + api.emit(PRELOAD_ENTRIES, { + ids: [item.isLeaf ? item.id : item.children[0]], + options: { target: refId }, + }); + } + }, []); + return ( {results.length > 0 && !query && ( @@ -255,6 +283,9 @@ export const SearchResults: FC<{ {...result} {...getItemProps({ key, index, item: result })} isHighlighted={highlightedIndex === index} + data-id={result.item.id} + data-refid={result.item.refId} + onMouseOver={mouseOverHandler} className="search-result-item" /> ); diff --git a/code/lib/ui/src/components/sidebar/Tree.tsx b/code/lib/ui/src/components/sidebar/Tree.tsx index e99a572008cc..dd68ff0db5ca 100644 --- a/code/lib/ui/src/components/sidebar/Tree.tsx +++ b/code/lib/ui/src/components/sidebar/Tree.tsx @@ -1,9 +1,11 @@ +import { useStorybookApi } from '@storybook/api'; import type { StoriesHash, GroupEntry, ComponentEntry, StoryEntry } from '@storybook/api'; import { styled } from '@storybook/theming'; import { Button, Icons } from '@storybook/components'; import { transparentize } from 'polished'; import React, { MutableRefObject, useCallback, useMemo, useRef } from 'react'; +import { PRELOAD_ENTRIES } from '@storybook/core-events'; import { ComponentNode, DocumentNode, @@ -147,6 +149,7 @@ const Node = React.memo( setExpanded, onSelectStoryId, }) => { + const api = useStorybookApi(); if (!isDisplayed) return null; const id = createId(item.id, refId); @@ -246,6 +249,14 @@ const Node = React.memo( setExpanded({ ids: [item.id], value: !isExpanded }); if (item.type === 'component' && !isExpanded) onSelectStoryId(item.id); }} + onMouseEnter={() => { + if (item.isComponent) { + api.emit(PRELOAD_ENTRIES, { + ids: [item.children[0]], + options: { target: refId }, + }); + } + }} > {(item.renderLabel as (i: typeof item) => React.ReactNode)?.(item) || item.name} diff --git a/code/lib/ui/src/components/sidebar/useHighlighted.ts b/code/lib/ui/src/components/sidebar/useHighlighted.ts index 8c87e56805ab..5aa2d8e8ce4e 100644 --- a/code/lib/ui/src/components/sidebar/useHighlighted.ts +++ b/code/lib/ui/src/components/sidebar/useHighlighted.ts @@ -8,6 +8,8 @@ import { useRef, useState, } from 'react'; +import { useStorybookApi } from '@storybook/api'; +import { PRELOAD_ENTRIES } from '@storybook/core-events'; import { matchesKeyCode, matchesModifiers } from '../../keybinding'; import { CombinedDataset, Highlight, Selection } from './types'; @@ -40,6 +42,7 @@ export const useHighlighted = ({ const initialHighlight = fromSelection(selected); const highlightedRef = useRef(initialHighlight); const [highlighted, setHighlighted] = useState(initialHighlight); + const api = useStorybookApi(); const updateHighlighted = useCallback( (highlight) => { @@ -109,6 +112,17 @@ export const useHighlighted = ({ const nextIndex = cycle(highlightable, currentIndex, isArrowUp ? -1 : 1); const didRunAround = isArrowUp ? nextIndex === highlightable.length - 1 : nextIndex === 0; highlightElement(highlightable[nextIndex], didRunAround); + + if (highlightable[nextIndex].getAttribute('data-nodetype') === 'component') { + const { itemId, refId } = highlightedRef.current; + const item = api.getData(itemId, refId === 'storybook_internal' ? undefined : refId); + if (item.isComponent) { + api.emit(PRELOAD_ENTRIES, { + ids: [item.isLeaf ? item.id : item.children[0]], + options: { target: refId }, + }); + } + } }); }; diff --git a/code/lib/ui/src/globals/exports.ts b/code/lib/ui/src/globals/exports.ts index 041f408c1191..b7fcfc8d3d30 100644 --- a/code/lib/ui/src/globals/exports.ts +++ b/code/lib/ui/src/globals/exports.ts @@ -120,7 +120,7 @@ export default { 'IGNORED_EXCEPTION', 'NAVIGATE_URL', 'PLAY_FUNCTION_THREW_EXCEPTION', - 'PRELOAD_STORIES', + 'PRELOAD_ENTRIES', 'PREVIEW_KEYDOWN', 'REGISTER_SUBSCRIPTION', 'RESET_STORY_ARGS',