diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2c66e469ef73..dc9785e14913 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -65,6 +65,12 @@ yarn nx compile -c production # Compile specific package yarn lint # Run all linting checks (~4 min) ``` +Fix linting on all touched files by running the following command before commiting: + +```bash +yarn --cwd code lint:js:cmd --fix +``` + ### Type Checking ```bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 256196a7e79a..aff4d1aca789 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 10.2.13 + +- Addon Pseudo-states: Process all nested css rules - [#33605](https://github.com/storybookjs/storybook/pull/33605), thanks @hpohlmeyer! +- Builder-Vite: Prevent config duplication - [#33883](https://github.com/storybookjs/storybook/pull/33883), thanks @copilot-swe-agent! +- CLI: Fix React native web A11y issues - [#33937](https://github.com/storybookjs/storybook/pull/33937), thanks @jonniebigodes! +- Core: Avoid hanging when inferring args for recursive calls on DOM elemens - [#33922](https://github.com/storybookjs/storybook/pull/33922), thanks @valentinpalkovic! +- Eslint: Fix ESLint 10 compatibility in eslint-plugin-storybook rules - [#33884](https://github.com/storybookjs/storybook/pull/33884), thanks @copilot-swe-agent! +- Viewport: Prioritize story viewport globals and avoid user-global pollution - [#33849](https://github.com/storybookjs/storybook/pull/33849), thanks @ia319! + ## 10.2.12 - Core: Sanitize inputs for save from controls - [#33868](https://github.com/storybookjs/storybook/pull/33868), thanks @valentinpalkovic! diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index a001a53b6fe2..1924a7e280f3 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -1,3 +1,12 @@ +## 10.3.0-alpha.13 + +- A11y: Add ScrollArea prop focusable for when it has static children - [#33876](https://github.com/storybookjs/storybook/pull/33876), thanks @Sidnioulz! +- CLI: Set STORYBOOK environment variable - [#33938](https://github.com/storybookjs/storybook/pull/33938), thanks @yannbf! +- Controls: Fix Object contrast issue and tidy up code - [#33923](https://github.com/storybookjs/storybook/pull/33923), thanks @Sidnioulz! +- HMR: Fix race conditions causing stale play functions to fire on re-rendered stories - [#33930](https://github.com/storybookjs/storybook/pull/33930), thanks @copilot-swe-agent! +- React: Handle render identifier in manifest snippet generation - [#33940](https://github.com/storybookjs/storybook/pull/33940), thanks @kasperpeulen! +- UI: Prevent crash when tag filters contain undefined entries - [#33931](https://github.com/storybookjs/storybook/pull/33931), thanks @abhaysinh1000! + ## 10.3.0-alpha.12 - Builder-Vite: Prevent config duplication - [#33883](https://github.com/storybookjs/storybook/pull/33883), thanks @copilot-swe-agent! diff --git a/code/addons/docs/src/blocks/controls/Object.tsx b/code/addons/docs/src/blocks/controls/Object.tsx index a817cd1e368a..dfaf5677ef62 100644 --- a/code/addons/docs/src/blocks/controls/Object.tsx +++ b/code/addons/docs/src/blocks/controls/Object.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps, FC, FocusEvent, SyntheticEvent } from 'react'; +import type { FC, FocusEvent, SyntheticEvent } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button, Form, ToggleButton } from 'storybook/internal/components'; @@ -6,7 +6,7 @@ import { Button, Form, ToggleButton } from 'storybook/internal/components'; import { AddIcon, SubtractIcon } from '@storybook/icons'; import { cloneDeep } from 'es-toolkit/object'; -import { type Theme, styled, useTheme } from 'storybook/theming'; +import { styled, useTheme } from 'storybook/theming'; import { getControlId, getControlSetterButtonId } from './helpers'; import { JsonTree } from './react-editable-json-tree'; @@ -14,8 +14,6 @@ import type { ControlProps, ObjectConfig, ObjectValue } from './types'; const { window: globalWindow } = globalThis; -type JsonTreeProps = ComponentProps; - const Wrapper = styled.div(({ theme }) => ({ position: 'relative', display: 'flex', @@ -39,8 +37,14 @@ const Wrapper = styled.div(({ theme }) => ({ alignItems: 'center', }, '.rejt-name': { + color: theme.color.secondary, lineHeight: '22px', }, + '.rejt-not-collapsed-list': { + listStyle: 'none', + margin: '0 0 0 1rem', + padding: 0, + }, '.rejt-not-collapsed-delimiter': { lineHeight: '22px', }, @@ -57,6 +61,9 @@ const Wrapper = styled.div(({ theme }) => ({ background: theme.base === 'light' ? theme.color.lighter : 'hsl(0 0 100 / 0.02)', borderColor: theme.appBorderColor, }, + '.rejt-collapsed-value': { + color: theme.color.defaultText, + }, })); const ButtonInline = styled.button<{ primary?: boolean }>(({ theme, primary }) => ({ @@ -155,23 +162,6 @@ const selectValue = (event: SyntheticEvent) => { export type ObjectProps = ControlProps & ObjectConfig; -const getCustomStyleFunction: (theme: Theme) => JsonTreeProps['getStyle'] = (theme) => () => ({ - name: { - color: theme.color.secondary, - }, - collapsed: { - color: theme.color.dark, - }, - ul: { - listStyle: 'none', - margin: '0 0 0 1rem', - padding: 0, - }, - li: { - outline: 0, - }, -}); - export const ObjectControl: FC = ({ name, value, onChange, argType }) => { const theme = useTheme(); const data = useMemo(() => value && cloneDeep(value), [value]); @@ -266,7 +256,6 @@ export const ObjectControl: FC = ({ name, value, onChange, argType data={data} rootName={name} onFullyUpdate={onChange} - getStyle={getCustomStyleFunction(theme)} cancelButtonElement={Cancel} addButtonElement={ diff --git a/code/addons/docs/src/blocks/controls/react-editable-json-tree/JsonNodeAccordion.tsx b/code/addons/docs/src/blocks/controls/react-editable-json-tree/JsonNodeAccordion.tsx index 75da2a484b41..19e29e8aad78 100644 --- a/code/addons/docs/src/blocks/controls/react-editable-json-tree/JsonNodeAccordion.tsx +++ b/code/addons/docs/src/blocks/controls/react-editable-json-tree/JsonNodeAccordion.tsx @@ -99,7 +99,7 @@ export function JsonNodeAccordion({ {name} : { renderCollapsed() { const { name, data, keyPath, deep } = this.state; - const { handleRemove, readOnly, getStyle, dataType, minusMenuElement } = this.props; - const { minus, collapsed } = getStyle(name, data, keyPath, deep, dataType); + const { handleRemove, readOnly, dataType, minusMenuElement } = this.props; const isReadOnly = readOnly(name, data, keyPath, deep, dataType); @@ -335,13 +334,12 @@ export class JsonArray extends Component { cloneElement(minusMenuElement, { onClick: handleRemove, className: 'rejt-minus-menu', - style: minus, 'aria-label': `remove the array '${String(name)}'`, }); return ( <> - + [...] {data.length} {data.length === 1 ? 'item' : 'items'} {!isReadOnly && removeItemButton} @@ -356,7 +354,6 @@ export class JsonArray extends Component { handleRemove, onDeltaUpdate, readOnly, - getStyle, dataType, addButtonElement, cancelButtonElement, @@ -370,7 +367,6 @@ export class JsonArray extends Component { logger, onSubmitValueParser, } = this.props; - const { minus, plus, delimiter, ul, addForm } = getStyle(name, data, keyPath, deep, dataType); const isReadOnly = readOnly(name, data, keyPath, deep, dataType); @@ -379,7 +375,6 @@ export class JsonArray extends Component { cloneElement(plusMenuElement, { onClick: this.handleAddMode, className: 'rejt-plus-menu', - style: plus, 'aria-label': `add a new item to the '${String(name)}' array`, }); const removeItemButton = @@ -387,7 +382,6 @@ export class JsonArray extends Component { cloneElement(minusMenuElement, { onClick: handleRemove, className: 'rejt-minus-menu', - style: minus, 'aria-label': `remove the array '${String(name)}'`, }); @@ -396,11 +390,9 @@ export class JsonArray extends Component { const endObject = ']'; return ( <> - - {startObject} - + {startObject} {!addFormVisible && addItemButton} -
    +
      {data.map((item, index) => ( { onUpdate={this.onChildUpdate} onDeltaUpdate={onDeltaUpdate} readOnly={readOnly} - getStyle={getStyle} addButtonElement={addButtonElement} cancelButtonElement={cancelButtonElement} inputElementGenerator={inputElementGenerator} @@ -430,7 +421,7 @@ export class JsonArray extends Component { ))}
    {!isReadOnly && addFormVisible && ( -
    +
    { />
    )} - - {endObject} - + {endObject} {!isReadOnly && removeItemButton} ); @@ -481,7 +470,6 @@ interface JsonArrayProps { onDeltaUpdate: (...args: any) => any; readOnly: (...args: any) => any; dataType?: string; - getStyle: (...args: any) => any; addButtonElement?: ReactElement; cancelButtonElement?: ReactElement; inputElementGenerator: (...args: any) => any; @@ -619,13 +607,11 @@ export class JsonFunctionValue extends Component - {textareaElementLayout} - - ); + result = {textareaElementLayout}; minusElement = null; } else { result = ( - + {value} ); @@ -670,7 +648,6 @@ export class JsonFunctionValue extends Component - - {name} :{' '} - +
  • + {name} : {result} {minusElement}
  • @@ -700,7 +675,6 @@ interface JsonFunctionValueProps { handleUpdateValue?: (...args: any) => any; readOnly: (...args: any) => any; dataType?: string; - getStyle: (...args: any) => any; cancelButtonElement?: ReactElement; textareaElementGenerator: (...args: any) => any; minusMenuElement?: ReactElement; @@ -748,7 +722,6 @@ export class JsonNode extends Component { onUpdate, onDeltaUpdate, readOnly, - getStyle, addButtonElement, cancelButtonElement, inputElementGenerator, @@ -778,7 +751,6 @@ export class JsonNode extends Component { onDeltaUpdate={onDeltaUpdate} readOnly={readOnlyTrue} dataType={dataType} - getStyle={getStyle} addButtonElement={addButtonElement} cancelButtonElement={cancelButtonElement} inputElementGenerator={inputElementGenerator} @@ -805,7 +777,6 @@ export class JsonNode extends Component { onDeltaUpdate={onDeltaUpdate} readOnly={readOnly} dataType={dataType} - getStyle={getStyle} addButtonElement={addButtonElement} cancelButtonElement={cancelButtonElement} inputElementGenerator={inputElementGenerator} @@ -832,7 +803,6 @@ export class JsonNode extends Component { onDeltaUpdate={onDeltaUpdate} readOnly={readOnly} dataType={dataType} - getStyle={getStyle} addButtonElement={addButtonElement} cancelButtonElement={cancelButtonElement} inputElementGenerator={inputElementGenerator} @@ -858,7 +828,6 @@ export class JsonNode extends Component { handleUpdateValue={handleUpdateValue} readOnly={readOnly} dataType={dataType} - getStyle={getStyle} cancelButtonElement={cancelButtonElement} inputElementGenerator={inputElementGenerator} minusMenuElement={minusMenuElement} @@ -878,7 +847,6 @@ export class JsonNode extends Component { handleUpdateValue={handleUpdateValue} readOnly={readOnly} dataType={dataType} - getStyle={getStyle} cancelButtonElement={cancelButtonElement} inputElementGenerator={inputElementGenerator} minusMenuElement={minusMenuElement} @@ -898,7 +866,6 @@ export class JsonNode extends Component { handleUpdateValue={handleUpdateValue} readOnly={readOnly} dataType={dataType} - getStyle={getStyle} cancelButtonElement={cancelButtonElement} inputElementGenerator={inputElementGenerator} minusMenuElement={minusMenuElement} @@ -918,7 +885,6 @@ export class JsonNode extends Component { handleUpdateValue={handleUpdateValue} readOnly={readOnlyTrue} dataType={dataType} - getStyle={getStyle} cancelButtonElement={cancelButtonElement} inputElementGenerator={inputElementGenerator} minusMenuElement={minusMenuElement} @@ -938,7 +904,6 @@ export class JsonNode extends Component { handleUpdateValue={handleUpdateValue} readOnly={readOnly} dataType={dataType} - getStyle={getStyle} cancelButtonElement={cancelButtonElement} inputElementGenerator={inputElementGenerator} minusMenuElement={minusMenuElement} @@ -958,7 +923,6 @@ export class JsonNode extends Component { handleUpdateValue={handleUpdateValue} readOnly={readOnly} dataType={dataType} - getStyle={getStyle} cancelButtonElement={cancelButtonElement} inputElementGenerator={inputElementGenerator} minusMenuElement={minusMenuElement} @@ -978,7 +942,6 @@ export class JsonNode extends Component { handleUpdateValue={handleUpdateValue} readOnly={readOnly} dataType={dataType} - getStyle={getStyle} cancelButtonElement={cancelButtonElement} textareaElementGenerator={textareaElementGenerator} minusMenuElement={minusMenuElement} @@ -998,7 +961,6 @@ export class JsonNode extends Component { handleUpdateValue={handleUpdateValue} readOnly={readOnlyTrue} dataType={dataType} - getStyle={getStyle} cancelButtonElement={cancelButtonElement} inputElementGenerator={inputElementGenerator} minusMenuElement={minusMenuElement} @@ -1023,7 +985,6 @@ interface JsonNodeProps { onUpdate: (...args: any) => any; onDeltaUpdate: (...args: any) => any; readOnly: (...args: any) => any; - getStyle: (...args: any) => any; addButtonElement?: ReactElement; cancelButtonElement?: ReactElement; inputElementGenerator: (...args: any) => any; @@ -1210,9 +1171,8 @@ export class JsonObject extends Component { renderCollapsed() { const { name, keyPath, deep, data } = this.state; - const { handleRemove, readOnly, dataType, getStyle, minusMenuElement } = this.props; + const { handleRemove, readOnly, dataType, minusMenuElement } = this.props; - const { minus, collapsed } = getStyle(name, data, keyPath, deep, dataType); const keyList = Object.getOwnPropertyNames(data); const isReadOnly = readOnly(name, data, keyPath, deep, dataType); @@ -1222,13 +1182,12 @@ export class JsonObject extends Component { cloneElement(minusMenuElement, { onClick: handleRemove, className: 'rejt-minus-menu', - style: minus, 'aria-label': `remove the object '${String(name)}'`, }); return ( <> - + {'{...}'} {keyList.length} {keyList.length === 1 ? 'key' : 'keys'} {!isReadOnly && removeItemButton} @@ -1243,7 +1202,6 @@ export class JsonObject extends Component { handleRemove, onDeltaUpdate, readOnly, - getStyle, dataType, addButtonElement, cancelButtonElement, @@ -1258,7 +1216,6 @@ export class JsonObject extends Component { onSubmitValueParser, } = this.props; - const { minus, plus, addForm, ul, delimiter } = getStyle(name, data, keyPath, deep, dataType); const keyList = Object.getOwnPropertyNames(data); const isReadOnly = readOnly(name, data, keyPath, deep, dataType); @@ -1268,7 +1225,6 @@ export class JsonObject extends Component { cloneElement(plusMenuElement, { onClick: this.handleAddMode, className: 'rejt-plus-menu', - style: plus, 'aria-label': `add a new property to the object '${String(name)}'`, }); const removeItemButton = @@ -1276,7 +1232,6 @@ export class JsonObject extends Component { cloneElement(minusMenuElement, { onClick: handleRemove, className: 'rejt-minus-menu', - style: minus, 'aria-label': `remove the object '${String(name)}'`, }); @@ -1293,7 +1248,6 @@ export class JsonObject extends Component { onUpdate={this.onChildUpdate} onDeltaUpdate={onDeltaUpdate} readOnly={readOnly} - getStyle={getStyle} addButtonElement={addButtonElement} cancelButtonElement={cancelButtonElement} inputElementGenerator={inputElementGenerator} @@ -1313,15 +1267,11 @@ export class JsonObject extends Component { return ( <> - - {startObject} - + {startObject} {!isReadOnly && addItemButton} -
      - {list} -
    +
      {list}
    {!isReadOnly && addFormVisible && ( -
    +
    { />
    )} - - {endObject} - + {endObject} {!isReadOnly && removeItemButton} ); @@ -1371,7 +1319,6 @@ interface JsonObjectProps { onDeltaUpdate: (...args: any) => any; readOnly: (...args: any) => any; dataType?: string; - getStyle: (...args: any) => any; addButtonElement?: ReactElement; cancelButtonElement?: ReactElement; inputElementGenerator: (...args: any) => any; @@ -1509,13 +1456,11 @@ export class JsonValue extends Component { originalValue, readOnly, dataType, - getStyle, inputElementGenerator, minusMenuElement, keyPath: comeFromKeyPath, } = this.props; - const style = getStyle(name, originalValue, keyPath, deep, dataType); const isReadOnly = readOnly(name, originalValue, keyPath, deep, dataType); const isEditing = editEnabled && !isReadOnly; const inputElement = inputElementGenerator( @@ -1540,28 +1485,21 @@ export class JsonValue extends Component { cloneElement(minusMenuElement, { onClick: handleRemove, className: 'rejt-minus-menu', - style: style.minus, 'aria-label': `remove the property '${String(name)}' with value '${String(originalValue)}'${ String(parentPropertyName) ? ` from '${String(parentPropertyName)}'` : '' }`, }); return ( -
  • - +
  • + {name} {' : '} {isEditing ? ( - - {inputElementLayout} - + {inputElementLayout} ) : ( - + {String(value)} )} @@ -1581,7 +1519,6 @@ interface JsonValueProps { handleUpdateValue?: (...args: any) => any; readOnly: (...args: any) => any; dataType?: string; - getStyle: (...args: any) => any; cancelButtonElement?: ReactElement; inputElementGenerator: (...args: any) => any; minusMenuElement?: ReactElement; diff --git a/code/addons/docs/src/blocks/controls/react-editable-json-tree/index.tsx b/code/addons/docs/src/blocks/controls/react-editable-json-tree/index.tsx index 465d8da87c8e..51c4f65cd75a 100644 --- a/code/addons/docs/src/blocks/controls/react-editable-json-tree/index.tsx +++ b/code/addons/docs/src/blocks/controls/react-editable-json-tree/index.tsx @@ -50,7 +50,6 @@ export class JsonTree extends Component { isCollapsed, onDeltaUpdate, readOnly, - getStyle, addButtonElement, cancelButtonElement, inputElement, @@ -94,7 +93,6 @@ export class JsonTree extends Component { onUpdate={this.onUpdate} onDeltaUpdate={onDeltaUpdate ?? (() => {})} readOnly={readOnlyFunction as (...args: any) => any} - getStyle={getStyle ?? (() => ({}))} addButtonElement={addButtonElement} cancelButtonElement={cancelButtonElement} inputElementGenerator={inputElementFunction as (...args: any) => any} @@ -123,7 +121,6 @@ interface JsonTreeProps { onFullyUpdate?: (...args: any) => any; onDeltaUpdate?: (...args: any) => any; readOnly?: boolean | ((...args: any) => any); - getStyle?: (...args: any) => any; addButtonElement?: ReactElement; cancelButtonElement?: ReactElement; inputElement?: ReactElement | ((...args: any) => ReactElement); @@ -142,17 +139,6 @@ interface JsonTreeProps { JsonTree.defaultProps = { rootName: 'root', isCollapsed: (keyPath, deep) => deep !== -1, - getStyle: (keyName, data, keyPath, deep, dataType) => { - switch (dataType) { - case 'Object': - case 'Error': - return object; - case 'Array': - return array; - default: - return value; - } - }, readOnly: () => false, onFullyUpdate: () => {}, onDeltaUpdate: () => {}, diff --git a/code/builders/builder-vite/src/codegen-modern-iframe-script.test.ts b/code/builders/builder-vite/src/codegen-modern-iframe-script.test.ts index 9b3e1c6c7625..5cc1e04d0620 100644 --- a/code/builders/builder-vite/src/codegen-modern-iframe-script.test.ts +++ b/code/builders/builder-vite/src/codegen-modern-iframe-script.test.ts @@ -25,11 +25,9 @@ describe('generateModernIframeScriptCodeFromPreviews', () => { window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore; if (import.meta.hot) { - import.meta.hot.on('vite:afterUpdate', () => { - window.__STORYBOOK_PREVIEW__.channel.emit('storyHotUpdated'); - }); - import.meta.hot.accept('virtual:/@storybook/builder-vite/storybook-stories.js', (newModule) => { + // Cancel any running play function before patching in the new importFn + window.__STORYBOOK_PREVIEW__.channel.emit('storyHotUpdated'); // importFn has changed so we need to patch the new one in window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn }); }); @@ -57,11 +55,9 @@ describe('generateModernIframeScriptCodeFromPreviews', () => { window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore; if (import.meta.hot) { - import.meta.hot.on('vite:afterUpdate', () => { - window.__STORYBOOK_PREVIEW__.channel.emit('storyHotUpdated'); - }); - import.meta.hot.accept('virtual:/@storybook/builder-vite/storybook-stories.js', (newModule) => { + // Cancel any running play function before patching in the new importFn + window.__STORYBOOK_PREVIEW__.channel.emit('storyHotUpdated'); // importFn has changed so we need to patch the new one in window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn }); }); @@ -89,11 +85,9 @@ describe('generateModernIframeScriptCodeFromPreviews', () => { window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore; if (import.meta.hot) { - import.meta.hot.on('vite:afterUpdate', () => { - window.__STORYBOOK_PREVIEW__.channel.emit('storyHotUpdated'); - }); - import.meta.hot.accept('virtual:/@storybook/builder-vite/storybook-stories.js', (newModule) => { + // Cancel any running play function before patching in the new importFn + window.__STORYBOOK_PREVIEW__.channel.emit('storyHotUpdated'); // importFn has changed so we need to patch the new one in window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn }); }); @@ -121,11 +115,9 @@ describe('generateModernIframeScriptCodeFromPreviews', () => { window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore; if (import.meta.hot) { - import.meta.hot.on('vite:afterUpdate', () => { - window.__STORYBOOK_PREVIEW__.channel.emit('storyHotUpdated'); - }); - import.meta.hot.accept('virtual:/@storybook/builder-vite/storybook-stories.js', (newModule) => { + // Cancel any running play function before patching in the new importFn + window.__STORYBOOK_PREVIEW__.channel.emit('storyHotUpdated'); // importFn has changed so we need to patch the new one in window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn }); }); diff --git a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts index e8a615db5b59..ac7f3026d957 100644 --- a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts +++ b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts @@ -31,11 +31,9 @@ export async function generateModernIframeScriptCodeFromPreviews(options: { return dedent` if (import.meta.hot) { - import.meta.hot.on('vite:afterUpdate', () => { - window.__STORYBOOK_PREVIEW__.channel.emit('${STORY_HOT_UPDATED}'); - }); - import.meta.hot.accept('${SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE}', (newModule) => { + // Cancel any running play function before patching in the new importFn + window.__STORYBOOK_PREVIEW__.channel.emit('${STORY_HOT_UPDATED}'); // importFn has changed so we need to patch the new one in window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn }); }); diff --git a/code/builders/builder-vite/src/codegen-project-annotations.ts b/code/builders/builder-vite/src/codegen-project-annotations.ts index 9d13c9390ec9..c878027239d0 100644 --- a/code/builders/builder-vite/src/codegen-project-annotations.ts +++ b/code/builders/builder-vite/src/codegen-project-annotations.ts @@ -1,4 +1,5 @@ import { getFrameworkName, loadPreviewOrConfigFile } from 'storybook/internal/common'; +import { STORY_HOT_UPDATED } from 'storybook/internal/core-events'; import { isCsfFactoryPreview, readConfig } from 'storybook/internal/csf-tools'; import type { Options, PreviewAnnotation } from 'storybook/internal/types'; @@ -68,6 +69,8 @@ export function generateProjectAnnotationsCodeFromPreviews(options: { if (import.meta.hot) { import.meta.hot.accept([${JSON.stringify(previewFileURL)}], (previewAnnotationModules) => { + // Cancel any running play function before patching in the new getProjectAnnotations + window?.__STORYBOOK_PREVIEW__?.channel?.emit('${STORY_HOT_UPDATED}'); // getProjectAnnotations has changed so we need to patch the new one in window?.__STORYBOOK_PREVIEW__?.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules), @@ -96,6 +99,8 @@ export function generateProjectAnnotationsCodeFromPreviews(options: { if (import.meta.hot) { import.meta.hot.accept(${JSON.stringify(previewAnnotationURLs)}, (previewAnnotationModules) => { + // Cancel any running play function before patching in the new getProjectAnnotations + window?.__STORYBOOK_PREVIEW__?.channel?.emit('${STORY_HOT_UPDATED}'); // getProjectAnnotations has changed so we need to patch the new one in window?.__STORYBOOK_PREVIEW__?.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules), diff --git a/code/builders/builder-webpack5/templates/virtualModuleModernEntry.js b/code/builders/builder-webpack5/templates/virtualModuleModernEntry.js index c470929a6520..e6ded416e206 100644 --- a/code/builders/builder-webpack5/templates/virtualModuleModernEntry.js +++ b/code/builders/builder-webpack5/templates/virtualModuleModernEntry.js @@ -33,18 +33,16 @@ window.__STORYBOOK_STORY_STORE__ = preview.storyStore; window.__STORYBOOK_ADDONS_CHANNEL__ = channel; if (import.meta.webpackHot) { - import.meta.webpackHot.addStatusHandler((status) => { - if (status === 'idle') { - preview.channel.emit(STORY_HOT_UPDATED); - } - }); - import.meta.webpackHot.accept('{{storiesFilename}}', () => { + // Cancel any running play function before patching in the new importFn + preview.channel.emit(STORY_HOT_UPDATED); // importFn has changed so we need to patch the new one in preview.onStoriesChanged({ importFn }); }); import.meta.webpackHot.accept(['{{previewAnnotations}}'], () => { + // Cancel any running play function before patching in the new getProjectAnnotations + preview.channel.emit(STORY_HOT_UPDATED); // getProjectAnnotations has changed so we need to patch the new one in preview.onGetProjectAnnotationsChanged({ getProjectAnnotations }); }); diff --git a/code/core/src/bin/core.ts b/code/core/src/bin/core.ts index c930896899e7..e673405eac44 100644 --- a/code/core/src/bin/core.ts +++ b/code/core/src/bin/core.ts @@ -13,6 +13,7 @@ import { dev } from '../cli/dev'; import { globalSettings } from '../cli/globalSettings'; addToGlobalContext('cliVersion', version); +process.env.STORYBOOK = 'true'; /** * Core CLI for Storybook. diff --git a/code/core/src/components/components/ScrollArea/ScrollArea.stories.tsx b/code/core/src/components/components/ScrollArea/ScrollArea.stories.tsx index d04efff54258..1aef91e37405 100644 --- a/code/core/src/components/components/ScrollArea/ScrollArea.stories.tsx +++ b/code/core/src/components/components/ScrollArea/ScrollArea.stories.tsx @@ -123,3 +123,37 @@ export const CustomSize = () => ( ))} ); + +export const FocusableVertical = () => ( + + {list((i) => ( + + {i} +
    +
    + ))} +
    +); + +export const FocusableHorizontal = () => ( + +
    + {list((i) => ( + {i} + ))} +
    +
    +); + +export const FocusableBoth = () => ( + + {list((i) => ( + + {list((ii) => ( + {ii * i} + ))} +
    +
    + ))} +
    +); diff --git a/code/core/src/components/components/ScrollArea/ScrollArea.tsx b/code/core/src/components/components/ScrollArea/ScrollArea.tsx index 8a45f2056881..edbca799f1a9 100644 --- a/code/core/src/components/components/ScrollArea/ScrollArea.tsx +++ b/code/core/src/components/components/ScrollArea/ScrollArea.tsx @@ -11,6 +11,11 @@ export interface ScrollAreaProps { offset?: number; scrollbarSize?: number; scrollPadding?: number | string; + /** + * Set this to define a tabIndex on the scrollable content; only needed when content has no + * interactive elements. + */ + focusable?: boolean; } const ScrollAreaRoot = styled(ScrollAreaPrimitive.Root)<{ scrollbarsize: number; offset: number }>( @@ -23,10 +28,18 @@ const ScrollAreaRoot = styled(ScrollAreaPrimitive.Root)<{ scrollbarsize: number; }) ); -const ScrollAreaViewport = styled(ScrollAreaPrimitive.Viewport)({ - width: '100%', - height: '100%', -}); +const ScrollAreaViewport = styled(ScrollAreaPrimitive.Viewport)<{ focusable: boolean }>( + ({ focusable, theme }) => ({ + width: '100%', + height: '100%', + '&:focus': focusable + ? { + outline: `2px solid ${theme.color.secondary}`, + outlineOffset: -2, + } + : {}, + }) +); const ScrollAreaScrollbar = styled(ScrollAreaPrimitive.Scrollbar)<{ offset: number; @@ -89,11 +102,17 @@ export const ScrollArea = forwardRef( scrollbarSize = 6, scrollPadding = 0, className, + focusable = false, }, ref ) => ( - + {children} {horizontal && ( diff --git a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx index 58286cec773e..494afdc587e2 100644 --- a/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx +++ b/code/core/src/components/components/syntaxhighlighter/syntaxhighlighter.tsx @@ -91,7 +91,7 @@ const Wrapper = styled.div( ); const UnstyledScroller = ({ children, className }: ScrollAreaProps) => ( - + {children} ); diff --git a/code/core/src/core-server/utils/index-json.test.ts b/code/core/src/core-server/utils/index-json.test.ts index a12d58bfec3e..610c2bb9c6a6 100644 --- a/code/core/src/core-server/utils/index-json.test.ts +++ b/code/core/src/core-server/utils/index-json.test.ts @@ -644,20 +644,52 @@ describe('registerIndexJsonRoute', () => { onChange(`${workingDir}/src/nested/Button.stories.ts`); onChange(`${workingDir}/src/nested/Button.stories.ts`); - // Wait for first batch to be processed and emit (leading edge) + // With trailing-only debounce, the first emit only fires after the debounce period await vi.waitFor(() => { expect(mockServerChannel.emit).toHaveBeenCalledTimes(1); }); expect(mockServerChannel.emit).toHaveBeenCalledWith(STORY_INDEX_INVALIDATED); // Fire another change event after the first batch is processed - // This will trigger the trailing edge of the debounce + // This will trigger the trailing edge of the debounce again onChange(`${workingDir}/src/nested/Button.stories.ts`); - // Wait for trailing debounce to trigger second emit + // Wait for the trailing debounce to trigger a second emit await vi.waitFor(() => { expect(mockServerChannel.emit).toHaveBeenCalledTimes(2); }); }); + + it('only emits once per file change (no double-fire from leading+trailing edges)', async () => { + vi.mocked(debounce).mockImplementation( + (await vi.importActual('es-toolkit/function')) + .debounce + ); + + const mockServerChannel = { emit: vi.fn() } as any as ServerChannel; + registerIndexJsonRoute({ + app, + channel: mockServerChannel, + workingDir, + normalizedStories, + storyIndexGeneratorPromise: getStoryIndexGeneratorPromise(), + }); + + const watcher = Watchpack.mock.instances[0]; + const onChange = watcher.on.mock.calls[0][1]; + + // Fire a single change event + onChange(`${workingDir}/src/nested/Button.stories.ts`); + + // Wait for the trailing debounce to fire + await vi.waitFor(() => { + expect(mockServerChannel.emit).toHaveBeenCalledTimes(1); + }); + + // Ensure it was only called once (no double-fire from both leading and trailing edges) + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(mockServerChannel.emit).toHaveBeenCalledTimes(1); + expect(mockServerChannel.emit).toHaveBeenCalledWith(STORY_INDEX_INVALIDATED); + }); }); }); diff --git a/code/core/src/core-server/utils/index-json.ts b/code/core/src/core-server/utils/index-json.ts index daf33d195a62..9afd11a4646e 100644 --- a/code/core/src/core-server/utils/index-json.ts +++ b/code/core/src/core-server/utils/index-json.ts @@ -39,7 +39,7 @@ export function registerIndexJsonRoute({ normalizedStories: NormalizedStoriesSpecifier[]; }) { const maybeInvalidate = debounce(() => channel.emit(STORY_INDEX_INVALIDATED), DEBOUNCE, { - edges: ['leading', 'trailing'], + edges: ['trailing'], }); watchStorySpecifiers(normalizedStories, { workingDir }, async (path, removed) => { (await storyIndexGeneratorPromise).invalidate(path, removed); diff --git a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx index 38ec700e1c8c..fa5ed4914059 100644 --- a/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilterPanel.tsx @@ -18,9 +18,9 @@ import { styled } from 'storybook/theming'; import type { Link } from '../../../components/components/tooltip/TooltipLinkList'; export const groupByType = (filters: Filter[]) => - filters.reduce( + filters.filter(Boolean).reduce( (acc, filter) => { - acc[filter.type] = acc[filter.type] || []; + acc[filter.type] ??= []; acc[filter.type].push(filter); return acc; }, diff --git a/code/core/src/preview-api/modules/addons/hooks.ts b/code/core/src/preview-api/modules/addons/hooks.ts index b0c2edaf2c9c..34ff7c2257d7 100644 --- a/code/core/src/preview-api/modules/addons/hooks.ts +++ b/code/core/src/preview-api/modules/addons/hooks.ts @@ -218,7 +218,10 @@ const areDepsEqual = (deps: any[], nextDeps: any[]) => deps.length === nextDeps.length && deps.every((dep, i) => dep === nextDeps[i]); const invalidHooksError = () => - new Error('Storybook preview hooks can only be called inside decorators and story functions.'); + new Error( + 'Storybook preview hooks can only be called inside decorators and story functions.\n\n' + + "When combining Storybook hooks (e.g. useArgs) with framework hooks (e.g. React's useState, useEffect, useRef) in the same render function, use Storybook's equivalents from 'storybook/preview-api' instead: useState, useEffect, useRef, useMemo, useCallback, useReducer." + ); function getHooksContextOrNull< TRenderer extends Renderer, diff --git a/code/package.json b/code/package.json index acde0276fadf..ec5e2ca5c90c 100644 --- a/code/package.json +++ b/code/package.json @@ -220,5 +220,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "10.3.0-alpha.13" } diff --git a/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx b/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx index 9c8662fa2ace..674666d0b0ca 100644 --- a/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx @@ -221,6 +221,38 @@ test('CSF2 - with args', () => { ); }); +test('render: Template (identifier referencing local function)', () => { + const input = withCSF3(dedent` + const Template = (args) => + export const Interactive: Story = { render: Template } + `); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const Interactive = () => ;"` + ); +}); + +test('render: Template (identifier referencing local function declaration)', () => { + const input = withCSF3(dedent` + function Template(args) { return } + export const Interactive: Story = { render: Template } + `); + expect(generateExample(input)).toMatchInlineSnapshot(` + "function Interactive() { + return ; + }" + `); +}); + +test('render: Template (identifier referencing unresolvable function)', () => { + // When Template can't be resolved (e.g. imported), fall back to no-function JSX synthesis + const input = withCSF3(dedent` + export const Interactive: Story = { render: Template } + `); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const Interactive = () => ;"` + ); +}); + test('Custom Render', () => { const input = withCSF3(dedent` export const CustomRender: Story = { render: () => } @@ -242,6 +274,82 @@ test('CustomRenderWithOverideArgs only', async () => { ); }); +test('Meta level render: Template (identifier referencing local function)', async () => { + const input = dedent` + import type { Meta } from '@storybook/react'; + import { Button } from '@design-system/button'; + + const Template = (args) => ;"` + ); +}); + +test('Meta level render: Template (identifier referencing unresolvable function)', async () => { + const input = dedent` + import type { Meta } from '@storybook/react'; + import { Button } from '@design-system/button'; + + const meta: Meta = { + component: Button, + render: Template, + args: { + children: 'Click me' + } + }; + export default meta; + + export const Fallback = { + args: { foo: 'bar' } + }; + `; + // Falls back to no-function JSX synthesis using component name + expect(generateExample(input)).toMatchInlineSnapshot( + `"const Fallback = () => ;"` + ); +}); + +test('Story unresolvable render does not fall back to meta render', async () => { + // When a story has `render: ImportedTemplate` (unresolvable) and meta has an inline render, + // the story's render should take precedence — fall through to no-function JSX synthesis, + // NOT use meta's render function. + const input = dedent` + import type { Meta } from '@storybook/react'; + import { Button } from '@design-system/button'; + + const meta: Meta = { + component: Button, + render: (args) => ;"` + ); +}); + test('Meta level render', async () => { const input = dedent` import type { Meta } from '@storybook/react'; diff --git a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts index 5e94ae84d50b..1f079168c6f6 100644 --- a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts @@ -48,7 +48,7 @@ export function getCodeSnippet( (t.isStringLiteral(prop.node) && prop.node.value === 'bind'); if (obj.isIdentifier() && isBind) { - const resolved = resolveBindIdentifierInit(storyDeclaration, obj); + const resolved = resolveIdentifierInit(storyDeclaration, obj); if (resolved) { normalizedPath = resolved; @@ -118,28 +118,61 @@ export function getCodeSnippet( ? metaPath.get('properties').filter((p) => p.isObjectProperty()) : []; - const getRenderPath = (object: NodePath[]) => { + // Tri-state render resolution: distinguishes "no render property" from + // "render exists but couldn't be resolved" so that an unresolvable story-level + // render (e.g. `render: ImportedTemplate`) doesn't incorrectly fall back to meta's render. + type RenderResolution = + | { kind: 'missing' } + | { + kind: 'resolved'; + path: NodePath; + } + | { kind: 'unresolved' }; + + const getRenderPath = (object: NodePath[]): RenderResolution => { const renderPath = object.find((p) => keyOf(p.node) === 'render')?.get('value'); - if (renderPath?.isIdentifier()) { - componentName = renderPath.node.name; + if (!renderPath) { + return { kind: 'missing' }; } - if ( - renderPath && - !(renderPath.isArrowFunctionExpression() || renderPath.isFunctionExpression()) - ) { + + // If render is an identifier (e.g. `render: Template`), try to resolve it + if (renderPath.isIdentifier()) { + const resolved = resolveIdentifierInit(storyDeclaration, renderPath); + if ( + resolved && + (resolved.isArrowFunctionExpression() || + resolved.isFunctionExpression() || + resolved.isFunctionDeclaration()) + ) { + return { kind: 'resolved', path: resolved }; + } + // Render property exists but couldn't be resolved — don't fall back to meta's render + return { kind: 'unresolved' }; + } + + if (!(renderPath.isArrowFunctionExpression() || renderPath.isFunctionExpression())) { throw renderPath.buildCodeFrameError( 'Expected render to be an arrow function or function expression' ); } - return renderPath; + return { kind: 'resolved', path: renderPath }; }; - const metaRenderPath = getRenderPath(metaProps); - const renderPath = getRenderPath(storyProps); - - storyFn ??= renderPath ?? metaRenderPath; + const metaRender = getRenderPath(metaProps); + const storyRender = getRenderPath(storyProps); + + // Story render takes precedence. Only fall back to meta render when the story + // has no render property at all — NOT when it has one that couldn't be resolved. + if (!storyFn) { + storyFn = + storyRender.kind === 'resolved' + ? storyRender.path + : storyRender.kind === 'missing' && metaRender.kind === 'resolved' + ? metaRender.path + : undefined; + } // Collect args const metaArgs = metaArgsRecord(metaObj ?? null); @@ -201,7 +234,13 @@ export function getCodeSnippet( if (changed) { return t.isFunctionDeclaration(fn) - ? t.functionDeclaration(fn.id, [], t.blockStatement(newBody), fn.generator, fn.async) + ? t.functionDeclaration( + t.identifier(storyName), + [], + t.blockStatement(newBody), + fn.generator, + fn.async + ) : t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(storyName), @@ -212,7 +251,7 @@ export function getCodeSnippet( } return t.isFunctionDeclaration(fn) - ? fn + ? t.functionDeclaration(t.identifier(storyName), fn.params, fn.body, fn.generator, fn.async) : t.variableDeclaration('const', [t.variableDeclarator(t.identifier(storyName), fn)]); } @@ -541,17 +580,28 @@ function transformArgsSpreadsInJsx( return { node: t.jsxFragment(node.openingFragment, node.closingFragment, fragChildren), changed }; } -/** Resolve the initializer for an identifier used as `Template.bind(...)`. */ -function resolveBindIdentifierInit( - storyPath: NodePath, - identifier: NodePath -) { +/** Resolve the initializer for an identifier (e.g. `Template.bind({})` or `render: Template`). */ +function resolveIdentifierInit(storyPath: NodePath, identifier: NodePath) { const programPath = storyPath.findParent((p) => p.isProgram()) as NodePath | null; if (!programPath) { return null; } + // Check for function declarations: `function Template(args) { ... }` or `export function Template(args) { ... }` + for (const stmt of programPath.get('body')) { + if (stmt.isFunctionDeclaration() && stmt.node.id?.name === identifier.node.name) { + return stmt; + } + if (stmt.isExportNamedDeclaration()) { + const decl = stmt.get('declaration'); + if (decl.isFunctionDeclaration() && decl.node.id?.name === identifier.node.name) { + return decl; + } + } + } + + // Check for variable declarations: `const Template = (args) => ...` const declarators = programPath.get('body').flatMap((stmt) => { if (stmt.isVariableDeclaration()) { return stmt.get('declarations'); diff --git a/docs/get-started/frameworks/angular.mdx b/docs/get-started/frameworks/angular.mdx index 4b3d6593e5cf..c0bc4d30dd77 100644 --- a/docs/get-started/frameworks/angular.mdx +++ b/docs/get-started/frameworks/angular.mdx @@ -20,7 +20,7 @@ You can then get started [writing stories](../whats-a-story.mdx), [running tests {/* prettier-ignore-end */} + + + + If you're using Storybook's hooks API in the story's render function, **do not** mix them with React's hooks such as `useState`, `useEffect`, or `useRef`. This is because side effects and re-rendering triggered by React's hooks do not run through Storybook's hook context, which can cause an error on re-render. To manage state and side effects within a story, you must use Storybook's equivalent hooks, such as `useState`, `useEffect`, and `useRef` from `storybook/preview-api`. + + ## Mapping to complex arg values diff --git a/docs/writing-stories/decorators.mdx b/docs/writing-stories/decorators.mdx index 7acaf6bdfa33..2a31268544d9 100644 --- a/docs/writing-stories/decorators.mdx +++ b/docs/writing-stories/decorators.mdx @@ -62,7 +62,7 @@ The second argument to a decorator function is the **story context** which conta * `args` - the story arguments. You can use some [`args`](./args.mdx) in your decorators and drop them in the story implementation itself. * `argTypes`- Storybook's [argTypes](../api/arg-types.mdx) allow you to customize and fine-tune your stories [`args`](./args.mdx). * `globals` - Storybook-wide [globals](../essentials/toolbars-and-globals.mdx#globals). In particular you can use the [toolbars feature](../essentials/toolbars-and-globals.mdx#global-types-and-the-toolbar-annotation) to allow you to change these values using Storybook’s UI. -* `hooks` - Storybook's API hooks (e.g., useArgs). +* `hooks` - Storybook's API hooks (e.g., `useArgs`, `useGlobals`). These are available in both decorators and story render functions. When using these hooks in a render function alongside framework hooks (e.g., React's `useState`, `useEffect`), use Storybook's hook equivalents from `storybook/preview-api` instead to avoid errors on re-render. * `parameters`- the story's static metadata, most commonly used to control Storybook's behavior of features and addons. * `viewMode`- Storybook's current active window (e.g., canvas, docs). diff --git a/scripts/build-package.ts b/scripts/build-package.ts index a29be28fe155..03de3e7bfc3c 100644 --- a/scripts/build-package.ts +++ b/scripts/build-package.ts @@ -140,8 +140,8 @@ async function run() { ], { onCancel: () => process.exit(0) } ).then(({ watch, prod, todo }: { watch: boolean; prod: boolean; todo: Array }) => { - watchMode = watch; - prodMode = prod; + watchMode ??= watch; + prodMode ??= prod; return todo?.map((key) => tasks[key]); }); } diff --git a/scripts/check-package.ts b/scripts/check-package.ts index 3c62ce2d1f43..da2d8f422f66 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -97,7 +97,7 @@ async function run() { })), }, ]).then(({ watch, todo }: { watch: boolean; todo: Array }) => { - watchMode = watch; + watchMode ??= watch; return todo?.map((key) => tasks[key]); }); }