diff --git a/CHANGELOG.md b/CHANGELOG.md index e47ac3da22d8..1708a49b53e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 10.2.15 + +- Core: Storybook failed to load iframe.html when publishing - [#33896](https://github.com/storybookjs/storybook/pull/33896), thanks @danielalanbates! +- Manager-API: Update refs sequentially in experimental_setFilter - [#33958](https://github.com/storybookjs/storybook/pull/33958), thanks @ia319! +- React: Handle render identifier in manifest snippet generation - [#33940](https://github.com/storybookjs/storybook/pull/33940), thanks @kasperpeulen! + ## 10.2.14 - CLI: Set STORYBOOK environment variable - [#33938](https://github.com/storybookjs/storybook/pull/33938), thanks @yannbf! diff --git a/SECURITY.md b/SECURITY.md index 1e3ad889e024..d34652bb0f0a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,9 +2,13 @@ ## Supported Versions -We release patches for fixing security vulnerabilities, primarily focusing on the latest release only. +We release patches for security vulnerabilities, primarily focusing on the latest major version. -In the event of a high-risk vulnerability, we may backport the security fixes to the minor versions of the software, starting from the latest minor version up to the latest major release. The decision to backport security fixes to older versions will be made based on a risk assessment and the feasibility of implementing the patch in those versions. +Security fixes are backported to the previous two major versions only for vulnerabilities with High or Critical CVSS scores (7.0+). The decision to backport is made based on severity assessment and the feasibility of implementing the patch in those versions. + +- Latest major version: All security vulnerabilities +- Previous two major versions: High or Critical CVSS scores only +- Older versions: Not supported (Users should upgrade to a supported version) ## Reporting a Vulnerability diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index 157817fc7909..30a9b769eef9 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -705,9 +705,9 @@ export const init: ModuleFn = ({ await api.setIndex(index); const refs = await fullAPI.getRefs(); - Object.entries(refs).forEach(([refId, { internal_index, ...ref }]) => { - fullAPI.setRef(refId, { ...ref, storyIndex: internal_index }, true); - }); + for (const [refId, { internal_index, ...ref }] of Object.entries(refs)) { + await fullAPI.setRef(refId, { ...ref, storyIndex: internal_index }, true); + } provider.channel?.emit(SET_FILTER, { id }); }, diff --git a/code/core/src/manager-api/modules/url.ts b/code/core/src/manager-api/modules/url.ts index b7f37e179e59..e46494a34cf4 100644 --- a/code/core/src/manager-api/modules/url.ts +++ b/code/core/src/manager-api/modules/url.ts @@ -254,7 +254,8 @@ export const init: ModuleFn = (moduleArgs) => { base === 'origin' ? originAddress : base === 'network' ? networkAddress : pathname; const previewBase = refId ? refs[refId].url + '/iframe.html' - : global.PREVIEW_URL || `${managerBase.replace(/\/[^/]*$/, '/')}iframe.html`; + : global.PREVIEW_URL || + `${managerBase.replace(/\/[^/]*\.html$/, '').replace(/\/?$/, '/')}iframe.html`; const refParam = refId ? `&refId=${encodeURIComponent(refId)}` : ''; const { args = '', globals = '', ...otherParams } = queryParams; diff --git a/code/core/src/manager-api/tests/url.test.js b/code/core/src/manager-api/tests/url.test.js index aedbc591d13e..4b259bdf6599 100644 --- a/code/core/src/manager-api/tests/url.test.js +++ b/code/core/src/manager-api/tests/url.test.js @@ -486,4 +486,49 @@ describe('getStoryHrefs', () => { expect(managerHref).toEqual('/index.html?path=/story/test--story'); expect(previewHref).toEqual('/iframe.html?id=test--story&viewMode=story'); }); + + it('correctly links when hosted at a subpath without trailing slash', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/design-system', search: '' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story'); + expect(managerHref).toEqual('/design-system?path=/story/test--story'); + expect(previewHref).toEqual('/design-system/iframe.html?id=test--story&viewMode=story'); + }); + + it('correctly links when hosted at a subpath with trailing slash', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/design-system/', search: '' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story'); + expect(managerHref).toEqual('/design-system/?path=/story/test--story'); + expect(previewHref).toEqual('/design-system/iframe.html?id=test--story&viewMode=story'); + }); + + it('correctly links when hosted at a subpath with index.html', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/design-system/index.html', search: '' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story'); + expect(managerHref).toEqual('/design-system/index.html?path=/story/test--story'); + expect(previewHref).toEqual('/design-system/iframe.html?id=test--story&viewMode=story'); + }); }); diff --git a/code/package.json b/code/package.json index 2c0df74c9e48..5ccce1cc374e 100644 --- a/code/package.json +++ b/code/package.json @@ -220,5 +220,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "10.2.15" } 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/releases/index.mdx b/docs/releases/index.mdx index f371a2fd290b..8bcfc5e5c176 100644 --- a/docs/releases/index.mdx +++ b/docs/releases/index.mdx @@ -25,15 +25,18 @@ npm create storybook@next ## Supported Versions -We actively maintain the latest major version of Storybook. Within the current major, we patch only the latest minor version. Most fixes and new work go into the next minor (or sometimes major) and are not backported. Critical security fixes may be backported more broadly across the current major version, and in rare cases (such as for a short period immediately following a new major), to the previous major. - -For example, if the latest version is `9.2.1`: - -- We support `9.x.x` versions and release `9.2.x` patch versions -- Most fixes and new work will be released as `9.3.0-alpha.x` versions - - If the next release is a major version, it would be `10.0.0-alpha.x` -- We will backport critical security fixes to `9.1.x` or `9.0.x` -- Rarely, we may backport critical fixes to `8.6.x` as necessary +We actively maintain the latest major version of Storybook. Within the current major, we patch only the latest minor version. Most fixes and new work go into the next minor (or sometimes major) and are not backported. Critical security fixes may be backported more broadly based on severity: +- Latest major: Receives all security fixes +- Previous two majors: Receive security patches for **High or Critical [CVSS vulnerabilities](https://en.wikipedia.org/wiki/Common_Vulnerability_Scoring_System) only** +- Older versions: No longer recieves any patches + +For example, if the latest version is `10.2.1`: + +- We support `10.x.x` versions and release `10.2.x` patch versions +- Most fixes and new work will be released as `10.3.0-alpha.x` versions + - If the next release is a major version, it would be `11.0.0-alpha.x` +- We will backport **High or Critical** security fixes to the latest minor of `9.x.x` and `8.x.x` +- Versions `7.x.x` and older will not receive security patches For compatibility with other libraries and tools in the JavaScript ecosystem, please refer to the [compatibility tracker](https://github.com/storybookjs/storybook/issues/23279). diff --git a/docs/versions/latest.json b/docs/versions/latest.json index 1c293c4724c1..92e1e5334d1f 100644 --- a/docs/versions/latest.json +++ b/docs/versions/latest.json @@ -1 +1 @@ -{"version":"10.2.14","info":{"plain":"- CLI: Set STORYBOOK environment variable - [#33938](https://github.com/storybookjs/storybook/pull/33938), thanks @yannbf!\n- UI: Prevent crash when tag filters contain undefined entries - [#33931](https://github.com/storybookjs/storybook/pull/33931), thanks @abhaysinh1000!"}} \ No newline at end of file +{"version":"10.2.15","info":{"plain":"- Core: Storybook failed to load iframe.html when publishing - [#33896](https://github.com/storybookjs/storybook/pull/33896), thanks @danielalanbates!\n- Manager-API: Update refs sequentially in experimental_setFilter - [#33958](https://github.com/storybookjs/storybook/pull/33958), thanks @ia319!\n- React: Handle render identifier in manifest snippet generation - [#33940](https://github.com/storybookjs/storybook/pull/33940), thanks @kasperpeulen!"}} \ No newline at end of file