Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4053403
Process all nested css rules
hpohlmeyer Jan 21, 2026
bbc8f5d
Add tests
hpohlmeyer Jan 21, 2026
2118601
fix(viewport): prioritize story globals and prevent user-global viewp…
ia319 Feb 15, 2026
dc7d853
Core: Sanitize inputs for save from controls
valentinpalkovic Feb 18, 2026
2f85e31
Build: Optimize sandbox generation scripts in link mode
valentinpalkovic Feb 20, 2026
4ffbc7a
refactor: Remove unused 'stat' import from compile task
valentinpalkovic Feb 20, 2026
bc9b9df
Telemetry: Add project age
shilman Feb 24, 2026
bc5c582
Refine anonymous-id implementation and harden tests
valentinpalkovic Feb 24, 2026
48b74f7
Add tests for getAnonymousProjectId
valentinpalkovic Feb 24, 2026
3052206
Improve anonymous-id implementation and harden tests
valentinpalkovic Feb 24, 2026
35cc133
Fix data type
shilman Feb 24, 2026
036d55a
Merge pull request #33910 from storybookjs/shilman/add-project-since
valentinpalkovic Feb 24, 2026
97307d3
Merge branch 'next-release' into next
storybook-bot Feb 24, 2026
62d1aea
Merge pull request #33880 from storybookjs/valentin/optimize-sandbox-…
valentinpalkovic Feb 24, 2026
60cae62
Fix formatting
valentinpalkovic Feb 24, 2026
caa833c
Fix formatting
valentinpalkovic Feb 24, 2026
3c4f034
Core: Enhance input sanitization and add XSS prevention in story file…
valentinpalkovic Feb 24, 2026
4d8237d
Enhance escapeForTemplate implementation
valentinpalkovic Feb 24, 2026
12db183
Merge pull request #33868 from storybookjs/valentin/sanitize-save-fro…
valentinpalkovic Feb 24, 2026
29b0239
Merge branch 'next' into fix/tw4-selectors
yannbf Feb 24, 2026
10d1e88
Update CHANGELOG.md for v10.2.12 [skip ci]
storybook-bot Feb 24, 2026
e64e537
Merge remote-tracking branch 'upstream/next' into bug/33831-viewport-…
ia319 Feb 24, 2026
2751ffb
fix linting
yannbf Feb 24, 2026
6fc49d1
use playwright image for unit tests
yannbf Feb 24, 2026
6d1e517
fix(viewport): remove false default from normalizeGlobal in parseGlob…
ia319 Feb 24, 2026
ca086e8
Merge pull request #33849 from ia319/bug/33831-viewport-story-global-…
valentinpalkovic Feb 24, 2026
8e1bafb
Core: Avoid performance bottlenecks when infering args for recursive …
valentinpalkovic Feb 25, 2026
dc787d2
move tests to use stories for verification instead
yannbf Feb 25, 2026
bbca9fc
Merge pull request #33922 from storybookjs/valentin/fix-performance-i…
valentinpalkovic Feb 25, 2026
483a8f4
Merge pull request #33605 from hpohlmeyer/fix/tw4-selectors
yannbf Feb 25, 2026
2f3ccdd
Write changelog for 10.3.0-alpha.11 [skip ci]
storybook-bot Feb 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 10.2.12

- Core: Sanitize inputs for save from controls - [#33868](https://github.com/storybookjs/storybook/pull/33868), thanks @valentinpalkovic!
- Telemetry: Add project age - [#33910](https://github.com/storybookjs/storybook/pull/33910), thanks @shilman!
- Webpack: Improve performance of module-mocking plugins - [#33169](https://github.com/storybookjs/storybook/pull/33169), thanks @valentinpalkovic!

## 10.2.11

- Addon-Vitest: Fix postinstall a11y installation - [#33888](https://github.com/storybookjs/storybook/pull/33888), thanks @valentinpalkovic!
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.prerelease.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
## 10.3.0-alpha.11

- Addon Pseudo-states: Process all nested css rules - [#33605](https://github.com/storybookjs/storybook/pull/33605), thanks @hpohlmeyer!
- Core: Avoid hanging when inferring args for recursive calls on DOM elemens - [#33922](https://github.com/storybookjs/storybook/pull/33922), thanks @valentinpalkovic!
- Core: Sanitize inputs for save from controls - [#33868](https://github.com/storybookjs/storybook/pull/33868), thanks @valentinpalkovic!
- Telemetry: Add project age - [#33910](https://github.com/storybookjs/storybook/pull/33910), thanks @shilman!
- Viewport: Prioritize story viewport globals and avoid user-global pollution - [#33849](https://github.com/storybookjs/storybook/pull/33849), thanks @ia319!

## 10.3.0-alpha.10

- Addon-Vitest: Fix postinstall a11y installation - [#33888](https://github.com/storybookjs/storybook/pull/33888), thanks @valentinpalkovic!
Expand Down
26 changes: 15 additions & 11 deletions code/addons/pseudo-states/src/preview/rewriteStyleSheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,24 +198,28 @@ const rewriteRuleContainer = (
// @ts-expect-error We're adding this nonstandard property below
numRewritten = cssRule.__pseudoStatesRewrittenCount;
} else {
if ('cssRules' in cssRule && (cssRule.cssRules as CSSRuleList).length) {
numRewritten = rewriteRuleContainer(
cssRule as CSSGroupingRule,
rewriteLimit - count,
forShadowDOM
);
} else {
if (!('selectorText' in cssRule)) {
continue;
}
const styleRule = cssRule as CSSStyleRule;
let styleRule = cssRule as CSSStyleRule;

// Modify the rule, if it contains a pseudo state
if ('selectorText' in styleRule) {
if (matchOne.test(styleRule.selectorText)) {
const newRule = rewriteRule(styleRule, forShadowDOM);
ruleContainer.deleteRule(index);
ruleContainer.insertRule(newRule, index);
styleRule = ruleContainer.cssRules[index] as CSSStyleRule;
numRewritten = 1;
}
}

// If it has nested rules, check them as well
if ('cssRules' in styleRule && (styleRule.cssRules as CSSRuleList).length) {
numRewritten = rewriteRuleContainer(
styleRule as CSSGroupingRule,
rewriteLimit - count,
forShadowDOM
);
}

// @ts-expect-error We're adding this nonstandard property
cssRule.__processed = true;
// @ts-expect-error We're adding this nonstandard property
Expand Down
25 changes: 25 additions & 0 deletions code/addons/pseudo-states/src/stories/NestedRules.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/react-vite';

import { Button } from './NestedRules';

const meta = {
title: 'NestedRules',
component: Button,
render: (args, context) => <Button {...args}>{context.name}</Button>,
} satisfies Meta<typeof Button>;

export default meta;

type Story = StoryObj<typeof meta>;

export const NestedHover: Story = {
parameters: {
pseudo: { focusVisible: true },
},
// TODO: Use this test once the pseudostates addon uses the beforeEach API
// play: async ({ canvas }) => {
// const button = canvas.getByRole('button')!;
// await expect(getComputedStyle(button).textDecorationLine).toBe('underline');
// await expect(getComputedStyle(button).textDecorationColor).toBe('rgb(255, 0, 0)');
// },
};
7 changes: 7 additions & 0 deletions code/addons/pseudo-states/src/stories/NestedRules.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import React from 'react';

import './nested.css';

export const Button = (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button className="nested-focus-visible" {...props} />
);
21 changes: 21 additions & 0 deletions code/addons/pseudo-states/src/stories/nested.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
button {
display: inline-block;
cursor: pointer;
border: 0;
border-radius: 3em;
background-color: #1ea7fd;
padding: 11px 20px;
color: white;
font-weight: 700;
font-size: 14px;
line-height: 1;
font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}

.nested-focus-visible {
&:focus-visible {
@supports (color: color-mix(in lab, red, red)) {
text-decoration: underline red;
}
}
}
36 changes: 36 additions & 0 deletions code/core/src/core-server/utils/get-new-story-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,42 @@ describe('get-new-story-file', () => {
expect(storyFileContent).not.toContain(STORYBOOK_FN_PLACEHOLDER);
});

it('should prevent XSS by escaping special characters in the component file name', async () => {
const { storyFileContent } = await getNewStoryFile(
{
componentFilePath: "src/stories/Button';alert(document.domain);var a='.tsx",
componentExportName: 'Button',
componentIsDefaultExport: true,
componentExportCount: 1,
},
{
presets: {
apply: (val: string) => {
if (val === 'framework') {
return Promise.resolve('@storybook/nextjs');
}
},
},
} as unknown as Options
);

expect(storyFileContent).toMatchInlineSnapshot(`
"import type { Meta, StoryObj } from '@storybook/nextjs';

import Buttonalert(documentDomain);varA=\\' from './Button\\';alert(document.domain);var a=\\'';

const meta = {
component: Buttonalert(documentDomain);varA=\\',
} satisfies Meta<typeof Buttonalert(documentDomain);varA=\\'>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {};"
`);
});

it('should create a new story file (CSF factory)', async () => {
const configDir = join(__dirname, '.storybook');
const previewConfigPath = join(configDir, 'preview.ts');
Expand Down
7 changes: 5 additions & 2 deletions code/core/src/core-server/utils/get-new-story-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { getCsfFactoryTemplateForNewStoryFile } from './new-story-templates/csf-factory-template';
import { getJavaScriptTemplateForNewStoryFile } from './new-story-templates/javascript';
import { getTypeScriptTemplateForNewStoryFile } from './new-story-templates/typescript';
import { escapeForTemplate } from './safeString';

export async function getNewStoryFile(
{
Expand All @@ -41,7 +42,7 @@ export async function getNewStoryFile(

const base = basename(componentFilePath);
const extension = extname(componentFilePath);
const basenameWithoutExtension = base.replace(extension, '');
const basenameWithoutExtension = escapeForTemplate(base.replace(extension, ''));
const dir = dirname(componentFilePath);

const { storyFileName, isTypescript, storyFileExtension } = getStoryMetadata(componentFilePath);
Expand Down Expand Up @@ -98,7 +99,9 @@ export async function getNewStoryFile(
const storyFilePath = join(getProjectRoot(), dir);
const relPath = relative(storyFilePath, previewConfigPath);
const pathWithoutExt = relPath.replace(/\.(ts|js|mts|cts|tsx|jsx)$/, '');
previewImportPath = pathWithoutExt.startsWith('.') ? pathWithoutExt : `./${pathWithoutExt}`;
previewImportPath = escapeForTemplate(
pathWithoutExt.startsWith('.') ? pathWithoutExt : `./${pathWithoutExt}`
);
}
}

Expand Down
36 changes: 36 additions & 0 deletions code/core/src/core-server/utils/safeString.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';

import { escapeForTemplate } from './safeString';

describe('safeString', () => {
describe('escapeForTemplate', () => {
it('should escape backticks in template strings', () => {
expect(escapeForTemplate('button`s.tsx')).toMatchInlineSnapshot('"button\\`s.tsx"');
});

it('should escape dollar signs for template expressions', () => {
expect(escapeForTemplate('button$file.tsx')).toMatchInlineSnapshot('"button\\$file.tsx"');
});

it('should escape backslashes', () => {
expect(escapeForTemplate('button\\file.tsx')).toMatchInlineSnapshot('"button\\\\file.tsx"');
});

it('should escape quotes', () => {
expect(escapeForTemplate("button's.tsx")).toMatchInlineSnapshot(`"button\\'s.tsx"`);
expect(escapeForTemplate('button"s.tsx')).toMatchInlineSnapshot(`"button\\"s.tsx"`);
});

it('should handle multiple special characters', () => {
expect(escapeForTemplate('button`${file}\\path.tsx')).toMatchInlineSnapshot(
`"button\\\`\\\${file}\\\\path.tsx"`
);
});

it('should preserve normal file paths', () => {
expect(escapeForTemplate('./src/components/Button.tsx')).toMatchInlineSnapshot(
'"./src/components/Button.tsx"'
);
});
});
});
18 changes: 18 additions & 0 deletions code/core/src/core-server/utils/safeString.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Escape special characters in a string for safe use within template literals in generated code.
* This escapes backticks and template expression delimiters.
*
* @example
*
* ```ts
* const fileName = "button's.tsx";
* const template = `import Button from './${escapeForTemplate(fileName)}'`;
* // Results in: import Button from './button\\'s.tsx'
* ```
*/
export function escapeForTemplate(str: string): string {
return str
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/(['"$`])/g, '\\$&') // Then escape quotes, dollar signs, and backticks
.replace(/[\n\r]/g, '\\$&'); // Then newlines
}
34 changes: 28 additions & 6 deletions code/core/src/preview-api/modules/store/inferArgTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { dedent } from 'ts-dedent';

import { combineParameters } from './parameters';

const inferType = (value: any, name: string, visited: Set<any>): SBType => {
const inferType = (
value: any,
name: string,
visited: Set<any>,
cache: Map<any, SBType>
): SBType => {
const type = typeof value;
switch (type) {
case 'boolean':
Expand All @@ -19,6 +24,12 @@ const inferType = (value: any, name: string, visited: Set<any>): SBType => {
break;
}
if (value) {
// Check cache first for previously computed results
if (cache.has(value)) {
return cache.get(value)!;
}

// Check for cycles (currently being processed in this path)
if (visited.has(value)) {
logger.warn(dedent`
We've detected a cycle in arg '${name}'. Args should be JSON-serializable.
Expand All @@ -29,25 +40,36 @@ const inferType = (value: any, name: string, visited: Set<any>): SBType => {
`);
return { name: 'other', value: 'cyclic object' };
}

visited.add(value);

let result: SBType;

if (Array.isArray(value)) {
const childType: SBType =
value.length > 0
? inferType(value[0], name, new Set(visited))
? inferType(value[0], name, visited, cache)
: { name: 'other', value: 'unknown' };
return { name: 'array', value: childType };
result = { name: 'array', value: childType };
} else {
const fieldTypes = mapValues(value, (field) => inferType(field, name, visited, cache));
result = { name: 'object', value: fieldTypes };
}
const fieldTypes = mapValues(value, (field) => inferType(field, name, new Set(visited)));
return { name: 'object', value: fieldTypes };

visited.delete(value); // Remove from current path after processing
cache.set(value, result); // Cache the result for future lookups

return result;
}
return { name: 'object', value: {} };
};

export const inferArgTypes: ArgTypesEnhancer<Renderer> = (context) => {
const { id, argTypes: userArgTypes = {}, initialArgs = {} } = context;
const cache = new Map<any, SBType>();
const argTypes = mapValues(initialArgs, (arg, key) => ({
name: key,
type: inferType(arg, `${id}.${key}`, new Set()),
type: inferType(arg, `${id}.${key}`, new Set(), cache),
}));
const userArgTypesNames = mapValues(userArgTypes, (argType, key) => ({
name: key,
Expand Down
Loading
Loading