From 40534033eef0a8e35123f336fa11303e08d64c45 Mon Sep 17 00:00:00 2001 From: Henning Pohlmeyer Date: Wed, 21 Jan 2026 09:02:12 +0100 Subject: [PATCH 01/22] Process all nested css rules --- .../src/preview/rewriteStyleSheet.ts | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/code/addons/pseudo-states/src/preview/rewriteStyleSheet.ts b/code/addons/pseudo-states/src/preview/rewriteStyleSheet.ts index e41a7a62ea58..d9eedaef0208 100644 --- a/code/addons/pseudo-states/src/preview/rewriteStyleSheet.ts +++ b/code/addons/pseudo-states/src/preview/rewriteStyleSheet.ts @@ -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 From bbc8f5d1e959d5df783993a09268e4ced84e4a93 Mon Sep 17 00:00:00 2001 From: Henning Pohlmeyer Date: Wed, 21 Jan 2026 09:02:32 +0100 Subject: [PATCH 02/22] Add tests --- .../src/preview/rewriteStyleSheet.test.ts | 58 +++++++++++++++++++ code/addons/pseudo-states/vitest.config.ts | 7 +++ 2 files changed, 65 insertions(+) diff --git a/code/addons/pseudo-states/src/preview/rewriteStyleSheet.test.ts b/code/addons/pseudo-states/src/preview/rewriteStyleSheet.test.ts index c2381421640a..4560d17abc5c 100644 --- a/code/addons/pseudo-states/src/preview/rewriteStyleSheet.test.ts +++ b/code/addons/pseudo-states/src/preview/rewriteStyleSheet.test.ts @@ -3,6 +3,34 @@ import { describe, expect, it } from 'vitest'; import { rewriteStyleSheet } from './rewriteStyleSheet'; import { splitSelectors } from './splitSelectors'; +function getNestedRule(rule: any, index: number) { + if (!('cssRules' in rule)) { + throw new Error('Cannot get nested rule, because the rule is not a CSSGroupingRule.', rule); + } + return (rule as CSSGroupingRule).cssRules[index]; +} + +function ensureCSSStyleRule(rule: any) { + if (!(rule instanceof CSSStyleRule)) { + throw new Error(`Rule is not a CSSStyleRule, but a ${rule.constructor.name}`); + } + return rule; +} + +function ensureCSSLayerBlockRule(rule: any) { + if (!(rule instanceof CSSLayerBlockRule)) { + throw new Error(`Rule is not a BlockLayerRule, but a ${rule.constructor.name}`); + } + return rule; +} + +function ensureCSSMediaRule(rule: any) { + if (!(rule instanceof CSSMediaRule)) { + throw new Error(`Rule is not a CSSMediaRule, but a ${rule.constructor.name}`); + } + return rule; +} + function splitRules(cssText: string): string[] { let ruleStart: number | undefined; let depth = 0; @@ -450,6 +478,36 @@ describe('rewriteStyleSheet', () => { expect(sheet.cssRules[3].getSelectors()).toContain('.pseudo-hover-all .test2'); }); + it('rewrites deeply nested rules', () => { + const styleSheet = new CSSStyleSheet(); + styleSheet.replaceSync( + `@layer utilities { + .hover\\:text-red-500 { + @media (hover: hover) { + &:hover { + color: var(--color-red-500); + } + } + } + }` + ); + + rewriteStyleSheet(styleSheet); + + const layerRule = ensureCSSLayerBlockRule(getNestedRule(styleSheet, 0)); + expect(layerRule.name).toBe('utilities'); + + const twSelector = ensureCSSStyleRule(getNestedRule(layerRule, 0)); + expect(twSelector.selectorText).toBe('.hover\\:text-red-500'); + + const media = ensureCSSMediaRule(getNestedRule(twSelector, 0)); + expect(media.conditionText).toBe('(hover: hover)'); + + const hover = ensureCSSStyleRule(getNestedRule(media, 0)); + expect(hover).toBeInstanceOf(CSSStyleRule); + expect(hover.selectorText).toBe('&:hover, &.pseudo-hover, .pseudo-hover-all &'); + }); + it('rewrites rules inside "@media"', () => { const sheet = new Sheet( `@media (max-width: 790px) { diff --git a/code/addons/pseudo-states/vitest.config.ts b/code/addons/pseudo-states/vitest.config.ts index 0e10a6f31d49..54a7400f6fec 100644 --- a/code/addons/pseudo-states/vitest.config.ts +++ b/code/addons/pseudo-states/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig, mergeConfig } from 'vitest/config'; +import { playwright } from '@vitest/browser-playwright' import { vitestCommonConfig } from '../../vitest.shared'; @@ -9,6 +10,12 @@ export default mergeConfig( typecheck: { enabled: true, }, + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [{ browser: 'chromium' }], + }, }, }) ); From 2118601e78fd50191eff8ba56b802b7662efb8ea Mon Sep 17 00:00:00 2001 From: ia319 Date: Sun, 15 Feb 2026 12:57:05 +0800 Subject: [PATCH 03/22] fix(viewport): prioritize story globals and prevent user-global viewport pollution - prioritize story viewport globals when story viewport globals exist - remove story-to-user viewport global synchronization - reset invalid viewport options only when no story viewport global exists --- code/core/src/viewport/useViewport.ts | 32 +++++++++++++-------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/code/core/src/viewport/useViewport.ts b/code/core/src/viewport/useViewport.ts index 04be3db8445a..0e96de08f505 100644 --- a/code/core/src/viewport/useViewport.ts +++ b/code/core/src/viewport/useViewport.ts @@ -78,13 +78,17 @@ const parseGlobals = ( }; } - // Ensure URL-defined viewports (user globals) override story globals. - // Spreading is not sufficient here, because undefined would still override defined values. - const global = normalizeGlobal(globals?.[PARAM_KEY]); - const userGlobal = normalizeGlobal(userGlobals?.[PARAM_KEY]); - const storyGlobal = normalizeGlobal(storyGlobals?.[PARAM_KEY]); - const value = userGlobal?.value ?? storyGlobal?.value ?? global?.value; - const isRotated = userGlobal?.isRotated ?? storyGlobal?.isRotated ?? global?.isRotated ?? false; + const global = normalizeGlobal(globals?.[PARAM_KEY], false); + const userGlobal = normalizeGlobal(userGlobals?.[PARAM_KEY], false); + const storyGlobal = normalizeGlobal(storyGlobals?.[PARAM_KEY], false); + const storyHasViewport = PARAM_KEY in storyGlobals; + + // Story-level viewport globals override user globals for the current story. + const primaryGlobal = storyHasViewport ? storyGlobal : userGlobal; + const secondaryGlobal = storyHasViewport ? userGlobal : storyGlobal; + const value = primaryGlobal?.value ?? secondaryGlobal?.value ?? global?.value; + const isRotated = + primaryGlobal?.isRotated ?? secondaryGlobal?.isRotated ?? global?.isRotated ?? false; const keys = Object.keys(options); const isLocked = disable || PARAM_KEY in storyGlobals || !keys.length; @@ -185,27 +189,21 @@ export const useViewport = () => { [update, isRotated] ); - useEffect(() => { - // Reset the viewport to the story global value if the story defines one, regardless of URL state - if (PARAM_KEY in storyGlobals) { - update(normalizeGlobal(storyGlobals?.[PARAM_KEY], false)); - lastSelectedOption.current = undefined; - } - }, [storyGlobals, update]); - useEffect(() => { // Skip if parameter not loaded to avoid race condition with default MINIMAL_VIEWPORTS if (!parameter) { return; } - // Reset the viewport to the story global value if the URL state defines an invalid option + // Track valid options; if invalid and no story-level viewport is set, reset to default if (option) { if (Object.hasOwn(options, option)) { lastSelectedOption.current = option; } else { lastSelectedOption.current = undefined; - update(normalizeGlobal(storyGlobals?.[PARAM_KEY], false)); + if (!(PARAM_KEY in storyGlobals)) { + update({ value: undefined, isRotated: false }); + } } } }, [parameter, storyGlobals, options, option, update]); From dc7d85344aa3c358594f532f1a6a0540bc0a5afb Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 18 Feb 2026 14:53:06 +0100 Subject: [PATCH 04/22] Core: Sanitize inputs for save from controls --- .../create-new-story-channel.ts | 18 +++++++++++++- .../src/core-server/utils/safeString.test.ts | 24 +++++++++++++++++++ code/core/src/core-server/utils/safeString.ts | 16 +++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 code/core/src/core-server/utils/safeString.test.ts create mode 100644 code/core/src/core-server/utils/safeString.ts diff --git a/code/core/src/core-server/server-channel/create-new-story-channel.ts b/code/core/src/core-server/server-channel/create-new-story-channel.ts index 94967d7e3e17..c524d7dc4da2 100644 --- a/code/core/src/core-server/server-channel/create-new-story-channel.ts +++ b/code/core/src/core-server/server-channel/create-new-story-channel.ts @@ -14,6 +14,7 @@ import { telemetry } from 'storybook/internal/telemetry'; import type { CoreConfig, Options } from 'storybook/internal/types'; import { generateStoryFile } from '../utils/generate-story'; +import { safeJsString } from '../utils/safeString'; export function initCreateNewStoryChannel( channel: Channel, @@ -24,7 +25,22 @@ export function initCreateNewStoryChannel( channel.on( CREATE_NEW_STORYFILE_REQUEST, async (data: RequestData) => { - const result = await generateStoryFile(data.payload, options); + const { + componentFilePath, + componentExportName, + componentExportCount, + componentIsDefaultExport, + } = data.payload; + + const result = await generateStoryFile( + { + componentFilePath: safeJsString(componentFilePath), + componentExportName: safeJsString(componentExportName), + componentExportCount, + componentIsDefaultExport, + }, + options + ); if (result.success) { channel.emit(CREATE_NEW_STORYFILE_RESPONSE, { diff --git a/code/core/src/core-server/utils/safeString.test.ts b/code/core/src/core-server/utils/safeString.test.ts new file mode 100644 index 000000000000..17ec8115cc1b --- /dev/null +++ b/code/core/src/core-server/utils/safeString.test.ts @@ -0,0 +1,24 @@ +import { describe,expect,it } from "vitest"; +import {safeJsString} from './safeString'; + +describe('safeString', () => { + it('should escape single quotes characters', () => { + expect(safeJsString("./button.tsx'alert({ console.log('malicious code') })")).toMatchInlineSnapshot(`"./button.tsx\\'alert({ console.log(\\'malicious code\\') })"`); + }); + + it('should escape double quotes characters', () => { + expect(safeJsString('./button.tsx"alert({ console.log("malicious code") })')).toMatchInlineSnapshot(`"./button.tsx\\"alert({ console.log(\\"malicious code\\") })"`); + }); + + it('should escape backslashes characters', () => { + expect(safeJsString('const file = "\\nexports.ts"')).toMatchInlineSnapshot(`"const file = \\"\\\\nexports.ts\\""`); + }); + + it('should escape new line characters', () => { + expect(safeJsString('const file = "\nexports.ts"')).toMatchInlineSnapshot(`"const file = \\"\\nexports.ts\\""`); + }); + + it('should skip escaping if not needed', () => { + expect(safeJsString('./button.tsx')).toMatchInlineSnapshot(`"./button.tsx"`); + }); +}); \ No newline at end of file diff --git a/code/core/src/core-server/utils/safeString.ts b/code/core/src/core-server/utils/safeString.ts new file mode 100644 index 000000000000..e1bca7ae5b92 --- /dev/null +++ b/code/core/src/core-server/utils/safeString.ts @@ -0,0 +1,16 @@ + +/** + * Escape special characters in a string to make it safe for use in JavaScript strings. + */ +export function safeJsString(str: string): string { + return str.replace(/['"\\\b\f\n\r\t]/g, (char) => { + const codes: Record = { + "'": "\\'", + '"': '\\"', + "\\": "\\\\", + "\n": "\\n", + "\r": "\\r" + }; + return codes[char] || char; + }); +} \ No newline at end of file From 2f85e3131e36ed4ee902095838786064e686df42 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 20 Feb 2026 11:09:16 +0100 Subject: [PATCH 05/22] Build: Optimize sandbox generation scripts in link mode --- scripts/tasks/compile.ts | 18 +++++------------- scripts/utils/yarn.ts | 3 +++ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/scripts/tasks/compile.ts b/scripts/tasks/compile.ts index eaa4ef2a699f..22f5c01373f0 100644 --- a/scripts/tasks/compile.ts +++ b/scripts/tasks/compile.ts @@ -1,4 +1,4 @@ -import { readFile, rm } from 'node:fs/promises'; +import { readFile, rm, stat } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import type { Task } from '../task'; @@ -11,7 +11,6 @@ const amountOfVCPUs = 2; const parallel = `--parallel=${process.env.CI ? amountOfVCPUs - 1 : maxConcurrentTasks}`; -const linkedContents = `export * from '../../src/manager-api/index.ts';`; const linkCommand = `yarn nx run-many -t compile ${parallel}`; const noLinkCommand = `yarn nx run-many -t compile -c production ${parallel}`; @@ -20,19 +19,12 @@ export const compile: Task = { dependsOn: ['install'], async ready({ codeDir }, { link }) { try { - // To check if the code has been compiled as we need, we check the compiled output of - // `@storybook/preview`. To check if it has been built for publishing (i.e. `--no-link`), - // we check if it built types or references source files directly. - const contents = await readFile( - resolve(codeDir, './core/dist/manager-api/index.d.ts'), - 'utf8' - ); - const isLinkedContents = contents.indexOf(linkedContents) !== -1; - if (link) { - return isLinkedContents; + await readFile(resolve(codeDir, './core/dist/manager-api/index.js'), 'utf8'); + } else { + await readFile(resolve(codeDir, './core/dist/manager-api/index.d.ts'), 'utf8'); } - return !isLinkedContents; + return true; } catch (err) { return false; } diff --git a/scripts/utils/yarn.ts b/scripts/utils/yarn.ts index 16015b518f81..4684393653f5 100644 --- a/scripts/utils/yarn.ts +++ b/scripts/utils/yarn.ts @@ -98,6 +98,9 @@ export const addWorkaroundResolutions = async ({ const additionalReact19Resolutions = [ 'nextjs/default-ts', 'nextjs/prerelease', + 'nextjs-vite/15-ts', + 'nextjs-vite/default-ts', + 'nextjs-vite/14-ts', 'react-native-web-vite/expo-ts', ].includes(key) ? { From 4ffbc7a68b1841c59a88b6cab27d5931e1b37cbe Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 20 Feb 2026 13:55:26 +0100 Subject: [PATCH 06/22] refactor: Remove unused 'stat' import from compile task --- scripts/tasks/compile.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tasks/compile.ts b/scripts/tasks/compile.ts index 22f5c01373f0..ec48e01a6692 100644 --- a/scripts/tasks/compile.ts +++ b/scripts/tasks/compile.ts @@ -1,4 +1,4 @@ -import { readFile, rm, stat } from 'node:fs/promises'; +import { readFile, rm } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import type { Task } from '../task'; From bc9b9dfb63df53dcfd1b11b182a98c1d408dfe53 Mon Sep 17 00:00:00 2001 From: Michael Shilman Date: Tue, 24 Feb 2026 12:04:05 +0800 Subject: [PATCH 07/22] Telemetry: Add project age --- code/core/src/telemetry/anonymous-id.test.ts | 36 ++++++++++++++++++-- code/core/src/telemetry/anonymous-id.ts | 21 ++++++++++++ code/core/src/telemetry/telemetry.ts | 3 +- docs/configure/telemetry.mdx | 8 +++++ 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/code/core/src/telemetry/anonymous-id.test.ts b/code/core/src/telemetry/anonymous-id.test.ts index 8277ca547e85..5e48d39671ae 100644 --- a/code/core/src/telemetry/anonymous-id.test.ts +++ b/code/core/src/telemetry/anonymous-id.test.ts @@ -1,6 +1,12 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; -import { normalizeGitUrl, unhashedProjectId } from './anonymous-id'; +import { execSync } from 'child_process'; + +import { getProjectSince, normalizeGitUrl, unhashedProjectId } from './anonymous-id'; + +vi.mock('child_process', () => ({ + execSync: vi.fn(), +})); describe('normalizeGitUrl', () => { it('trims off https://', () => { @@ -105,3 +111,29 @@ describe('unhashedProjectId', () => { ).toBe('github.com/storybookjs/storybook.gitpath/to/storybook'); }); }); + +describe('getProjectSince', () => { + it('returns a Date from git log output', () => { + vi.mocked(execSync).mockReturnValue(Buffer.from('2024-06-15 18:23:01 +0000\n')); + + expect(getProjectSince()).toEqual(new Date('2024-06-15 18:23:01 +0000')); + expect(vi.mocked(execSync)).toHaveBeenCalledWith('git log --reverse --format=%cd --date=iso', { + timeout: 1000, + stdio: 'pipe', + }); + }); + + it('returns undefined if git log output is empty', () => { + vi.mocked(execSync).mockReturnValue(Buffer.from(' \n')); + + expect(getProjectSince()).toBeUndefined(); + }); + + it('returns undefined if git log fails', () => { + vi.mocked(execSync).mockImplementation(() => { + throw new Error('git not available'); + }); + + expect(getProjectSince()).toBeUndefined(); + }); +}); diff --git a/code/core/src/telemetry/anonymous-id.ts b/code/core/src/telemetry/anonymous-id.ts index 951a268a6263..0f28aff6a67b 100644 --- a/code/core/src/telemetry/anonymous-id.ts +++ b/code/core/src/telemetry/anonymous-id.ts @@ -53,3 +53,24 @@ export const getAnonymousProjectId = () => { return anonymousProjectId; }; + +export const getProjectSince = () => { + try { + const dateBuffer = execSync(`git log --reverse --format=%cd --date=iso`, { + timeout: 1000, + stdio: `pipe`, + }); + + const date = new Date(String(dateBuffer).trim()); + + if (Number.isNaN(date.getTime())) { + return undefined; + } + + return date; + } catch (_) { + // + } + + return undefined; +}; diff --git a/code/core/src/telemetry/telemetry.ts b/code/core/src/telemetry/telemetry.ts index b84ae26b47ac..cb5b903cd085 100644 --- a/code/core/src/telemetry/telemetry.ts +++ b/code/core/src/telemetry/telemetry.ts @@ -10,7 +10,7 @@ import { nanoid } from 'nanoid'; import { version } from '../../package.json'; import { resolvePackageDir } from '../shared/utils/module'; -import { getAnonymousProjectId } from './anonymous-id'; +import { getAnonymousProjectId, getProjectSince } from './anonymous-id'; import { detectAgent } from './detect-agent'; import { set as saveToCache } from './event-cache'; import { fetch } from './fetch'; @@ -107,6 +107,7 @@ export async function sendTelemetry( : { ...globalContext, anonymousId: getAnonymousProjectId(), + projectSince: getProjectSince()?.getTime(), }; let request: any; diff --git a/docs/configure/telemetry.mdx b/docs/configure/telemetry.mdx index 2d25e6d7ddeb..1dbd3b9ecc3c 100644 --- a/docs/configure/telemetry.mdx +++ b/docs/configure/telemetry.mdx @@ -62,6 +62,14 @@ Will generate the following output: { "anonymousId": "8bcfdfd5f9616a1923dd92adf89714331b2d18693c722e05152a47f8093392bb", "eventType": "dev", + "context": { + "isTTY": true, + "platform": "macOS", + "nodeVersion": "24.11.0", + "storybookVersion": "10.3.0-alpha.9", + "cliVersion": "10.3.0-alpha.9", + "projectSince": "1717334400000" + }, "payload": { "versionStatus": "cached", "storyIndex": { From bc5c582f907af88457db3c022d634fd1bab5333f Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 24 Feb 2026 09:00:56 +0100 Subject: [PATCH 08/22] Refine anonymous-id implementation and harden tests --- code/core/src/telemetry/anonymous-id.test.ts | 27 ++++++++++---------- code/core/src/telemetry/anonymous-id.ts | 19 ++++++++------ 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/code/core/src/telemetry/anonymous-id.test.ts b/code/core/src/telemetry/anonymous-id.test.ts index 5e48d39671ae..91c8e9796cd9 100644 --- a/code/core/src/telemetry/anonymous-id.test.ts +++ b/code/core/src/telemetry/anonymous-id.test.ts @@ -1,12 +1,17 @@ import { describe, expect, it, vi } from 'vitest'; -import { execSync } from 'child_process'; +import { executeCommandSync } from 'storybook/internal/common'; import { getProjectSince, normalizeGitUrl, unhashedProjectId } from './anonymous-id'; -vi.mock('child_process', () => ({ - execSync: vi.fn(), -})); +vi.mock(import('storybook/internal/common'), async (actualModule) => { + const actual = await actualModule(); + + return { + ...actual, + executeCommandSync: vi.fn(actual.executeCommandSync), + }; +}); describe('normalizeGitUrl', () => { it('trims off https://', () => { @@ -113,24 +118,18 @@ describe('unhashedProjectId', () => { }); describe('getProjectSince', () => { - it('returns a Date from git log output', () => { - vi.mocked(execSync).mockReturnValue(Buffer.from('2024-06-15 18:23:01 +0000\n')); - - expect(getProjectSince()).toEqual(new Date('2024-06-15 18:23:01 +0000')); - expect(vi.mocked(execSync)).toHaveBeenCalledWith('git log --reverse --format=%cd --date=iso', { - timeout: 1000, - stdio: 'pipe', - }); + it('returns the Storybook creation date from git log output', () => { + expect(getProjectSince()).toEqual(new Date('2015-12-11T10:54:01.000Z')); }); it('returns undefined if git log output is empty', () => { - vi.mocked(execSync).mockReturnValue(Buffer.from(' \n')); + vi.mocked(executeCommandSync).mockReturnValue(''); expect(getProjectSince()).toBeUndefined(); }); it('returns undefined if git log fails', () => { - vi.mocked(execSync).mockImplementation(() => { + vi.mocked(executeCommandSync).mockImplementation(() => { throw new Error('git not available'); }); diff --git a/code/core/src/telemetry/anonymous-id.ts b/code/core/src/telemetry/anonymous-id.ts index 0f28aff6a67b..9e73fe821bb1 100644 --- a/code/core/src/telemetry/anonymous-id.ts +++ b/code/core/src/telemetry/anonymous-id.ts @@ -1,8 +1,7 @@ import { relative } from 'node:path'; -import { getProjectRoot } from 'storybook/internal/common'; +import { executeCommandSync, getProjectRoot } from 'storybook/internal/common'; -import { execSync } from 'child_process'; // eslint-disable-next-line depend/ban-dependencies import slash from 'slash'; @@ -41,12 +40,13 @@ export const getAnonymousProjectId = () => { try { const projectRootPath = relative(getProjectRoot(), process.cwd()); - const originBuffer = execSync(`git config --local --get remote.origin.url`, { + const result = executeCommandSync({ + command: 'git', + args: ['config', '--get', 'remote.origin.url'], timeout: 1000, - stdio: `pipe`, }); - anonymousProjectId = oneWayHash(unhashedProjectId(String(originBuffer), projectRootPath)); + anonymousProjectId = oneWayHash(unhashedProjectId(result, projectRootPath)); } catch (_) { // } @@ -56,12 +56,15 @@ export const getAnonymousProjectId = () => { export const getProjectSince = () => { try { - const dateBuffer = execSync(`git log --reverse --format=%cd --date=iso`, { + const dateBuffer = executeCommandSync({ + command: 'git', + args: ['log', '--reverse', '--format=%cd', '--date=iso'], timeout: 1000, - stdio: `pipe`, }); - const date = new Date(String(dateBuffer).trim()); + const firstLine = String(dateBuffer).trim().split('\n')[0]; + + const date = new Date(firstLine); if (Number.isNaN(date.getTime())) { return undefined; From 48b74f7e7f8a0626954166dd013988212af182a5 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 24 Feb 2026 09:12:39 +0100 Subject: [PATCH 09/22] Add tests for getAnonymousProjectId --- code/core/src/telemetry/anonymous-id.test.ts | 38 ++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/code/core/src/telemetry/anonymous-id.test.ts b/code/core/src/telemetry/anonymous-id.test.ts index 91c8e9796cd9..cbd817fc2bec 100644 --- a/code/core/src/telemetry/anonymous-id.test.ts +++ b/code/core/src/telemetry/anonymous-id.test.ts @@ -1,8 +1,13 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { executeCommandSync } from 'storybook/internal/common'; -import { getProjectSince, normalizeGitUrl, unhashedProjectId } from './anonymous-id'; +import { + getAnonymousProjectId, + getProjectSince, + normalizeGitUrl, + unhashedProjectId, +} from './anonymous-id'; vi.mock(import('storybook/internal/common'), async (actualModule) => { const actual = await actualModule(); @@ -13,6 +18,10 @@ vi.mock(import('storybook/internal/common'), async (actualModule) => { }; }); +beforeEach(() => { + vi.mocked(executeCommandSync).mockReset(); +}); + describe('normalizeGitUrl', () => { it('trims off https://', () => { expect(normalizeGitUrl('https://github.com/storybookjs/storybook.git')).toEqual( @@ -136,3 +145,28 @@ describe('getProjectSince', () => { expect(getProjectSince()).toBeUndefined(); }); }); + +describe('getAnonymousProjectId', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it('returns hashed project id for Storybook repo when git command succeeds', async () => { + const result = getAnonymousProjectId(); + + expect(result).toMatch('061e4ee22a1f7c079849d97234b3be94d016fb1f24ba11878c41f8b48c0213bf'); + }); + + it('returns undefined when git command fails', async () => { + const { getAnonymousProjectId: getAnonId } = await import('./anonymous-id'); + + vi.mocked(executeCommandSync).mockImplementation(() => { + throw new Error('git not available'); + }); + + const result = getAnonId(); + + expect(result).toBeUndefined(); + }); +}); From 30522069baffef3bce45188b7ad4377c852edecd Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 24 Feb 2026 09:38:36 +0100 Subject: [PATCH 10/22] Improve anonymous-id implementation and harden tests --- code/core/src/telemetry/anonymous-id.test.ts | 27 ++++++++++++++++---- code/core/src/telemetry/anonymous-id.ts | 7 +++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/code/core/src/telemetry/anonymous-id.test.ts b/code/core/src/telemetry/anonymous-id.test.ts index cbd817fc2bec..cdee088797ab 100644 --- a/code/core/src/telemetry/anonymous-id.test.ts +++ b/code/core/src/telemetry/anonymous-id.test.ts @@ -15,6 +15,7 @@ vi.mock(import('storybook/internal/common'), async (actualModule) => { return { ...actual, executeCommandSync: vi.fn(actual.executeCommandSync), + getProjectRoot: () => '/path/to/project/root', }; }); @@ -127,22 +128,35 @@ describe('unhashedProjectId', () => { }); describe('getProjectSince', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + it('returns the Storybook creation date from git log output', () => { - expect(getProjectSince()).toEqual(new Date('2015-12-11T10:54:01.000Z')); + vi.mocked(executeCommandSync).mockReturnValue( + '2025-12-11 16:24:01 +0530\n' + '2014-12-11 19:09:10 +0530' + ); + + expect(getProjectSince()).toEqual(new Date('2025-12-11T10:54:01.000Z')); }); - it('returns undefined if git log output is empty', () => { + it('returns undefined if git log output is empty', async () => { vi.mocked(executeCommandSync).mockReturnValue(''); - expect(getProjectSince()).toBeUndefined(); + const { getProjectSince: getProjSince } = await import('./anonymous-id'); + + expect(getProjSince()).toBeUndefined(); }); - it('returns undefined if git log fails', () => { + it('returns undefined if git log fails', async () => { vi.mocked(executeCommandSync).mockImplementation(() => { throw new Error('git not available'); }); - expect(getProjectSince()).toBeUndefined(); + const { getProjectSince: getProjSince } = await import('./anonymous-id'); + + expect(getProjSince()).toBeUndefined(); }); }); @@ -150,9 +164,12 @@ describe('getAnonymousProjectId', () => { beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); + + vi.spyOn(process, 'cwd').mockReturnValue('/path/to/project/root'); }); it('returns hashed project id for Storybook repo when git command succeeds', async () => { + vi.mocked(executeCommandSync).mockReturnValue('git@github.com:storybookjs/storybook.git'); const result = getAnonymousProjectId(); expect(result).toMatch('061e4ee22a1f7c079849d97234b3be94d016fb1f24ba11878c41f8b48c0213bf'); diff --git a/code/core/src/telemetry/anonymous-id.ts b/code/core/src/telemetry/anonymous-id.ts index 9e73fe821bb1..a66fb88f0538 100644 --- a/code/core/src/telemetry/anonymous-id.ts +++ b/code/core/src/telemetry/anonymous-id.ts @@ -32,6 +32,8 @@ export function unhashedProjectId(remoteUrl: string, projectRootPath: string) { } let anonymousProjectId: string; +let getProjectSinceResult: Date | undefined; + export const getAnonymousProjectId = () => { if (anonymousProjectId) { return anonymousProjectId; @@ -56,6 +58,10 @@ export const getAnonymousProjectId = () => { export const getProjectSince = () => { try { + if (getProjectSinceResult) { + return getProjectSinceResult; + } + const dateBuffer = executeCommandSync({ command: 'git', args: ['log', '--reverse', '--format=%cd', '--date=iso'], @@ -70,6 +76,7 @@ export const getProjectSince = () => { return undefined; } + getProjectSinceResult = date; return date; } catch (_) { // From 35cc1333265693bfca67af97ae9738353664c698 Mon Sep 17 00:00:00 2001 From: Michael Shilman Date: Tue, 24 Feb 2026 16:45:13 +0800 Subject: [PATCH 11/22] Fix data type --- docs/configure/telemetry.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configure/telemetry.mdx b/docs/configure/telemetry.mdx index 1dbd3b9ecc3c..4aacc45029e7 100644 --- a/docs/configure/telemetry.mdx +++ b/docs/configure/telemetry.mdx @@ -68,7 +68,7 @@ Will generate the following output: "nodeVersion": "24.11.0", "storybookVersion": "10.3.0-alpha.9", "cliVersion": "10.3.0-alpha.9", - "projectSince": "1717334400000" + "projectSince": 1717334400000 }, "payload": { "versionStatus": "cached", From 60cae6256baa7b11e7c9624e248b92af38c20a17 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 24 Feb 2026 10:55:49 +0100 Subject: [PATCH 12/22] Fix formatting --- .../src/core-server/utils/safeString.test.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/code/core/src/core-server/utils/safeString.test.ts b/code/core/src/core-server/utils/safeString.test.ts index 17ec8115cc1b..020d2fc7bc54 100644 --- a/code/core/src/core-server/utils/safeString.test.ts +++ b/code/core/src/core-server/utils/safeString.test.ts @@ -1,24 +1,33 @@ -import { describe,expect,it } from "vitest"; -import {safeJsString} from './safeString'; +import { describe, expect, it } from 'vitest'; + +import { safeJsString } from './safeString'; describe('safeString', () => { it('should escape single quotes characters', () => { - expect(safeJsString("./button.tsx'alert({ console.log('malicious code') })")).toMatchInlineSnapshot(`"./button.tsx\\'alert({ console.log(\\'malicious code\\') })"`); + expect( + safeJsString("./button.tsx'alert({ console.log('malicious code') })") + ).toMatchInlineSnapshot(`"./button.tsx\\'alert({ console.log(\\'malicious code\\') })"`); }); it('should escape double quotes characters', () => { - expect(safeJsString('./button.tsx"alert({ console.log("malicious code") })')).toMatchInlineSnapshot(`"./button.tsx\\"alert({ console.log(\\"malicious code\\") })"`); + expect( + safeJsString('./button.tsx"alert({ console.log("malicious code") })') + ).toMatchInlineSnapshot(`"./button.tsx\\"alert({ console.log(\\"malicious code\\") })"`); }); - it('should escape backslashes characters', () => { - expect(safeJsString('const file = "\\nexports.ts"')).toMatchInlineSnapshot(`"const file = \\"\\\\nexports.ts\\""`); + it('should escape backslashes characters', () => { + expect(safeJsString('const file = "\\nexports.ts"')).toMatchInlineSnapshot( + `"const file = \\"\\\\nexports.ts\\""` + ); }); it('should escape new line characters', () => { - expect(safeJsString('const file = "\nexports.ts"')).toMatchInlineSnapshot(`"const file = \\"\\nexports.ts\\""`); + expect(safeJsString('const file = "\nexports.ts"')).toMatchInlineSnapshot( + `"const file = \\"\\nexports.ts\\""` + ); }); it('should skip escaping if not needed', () => { expect(safeJsString('./button.tsx')).toMatchInlineSnapshot(`"./button.tsx"`); }); -}); \ No newline at end of file +}); From caa833c89b4b6b3bc9a4a7f16869cb0e39607013 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 24 Feb 2026 10:58:08 +0100 Subject: [PATCH 13/22] Fix formatting --- code/core/src/core-server/utils/safeString.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/code/core/src/core-server/utils/safeString.ts b/code/core/src/core-server/utils/safeString.ts index e1bca7ae5b92..03a4d2ff45be 100644 --- a/code/core/src/core-server/utils/safeString.ts +++ b/code/core/src/core-server/utils/safeString.ts @@ -1,16 +1,15 @@ - -/** - * Escape special characters in a string to make it safe for use in JavaScript strings. - */ +/** Escape special characters in a string to make it safe for use in JavaScript strings. */ export function safeJsString(str: string): string { - return str.replace(/['"\\\b\f\n\r\t]/g, (char) => { + return str.replace(/['"\\\b\f\n\r\t`$]/g, (char) => { const codes: Record = { "'": "\\'", '"': '\\"', - "\\": "\\\\", - "\n": "\\n", - "\r": "\\r" + '\\': '\\\\', + '\n': '\\n', + '\r': '\\r', + '`': '\\`', + $: '\\$', }; return codes[char] || char; }); -} \ No newline at end of file +} From 3c4f03413f41dd1d882c280869ad606cdf755009 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 24 Feb 2026 12:08:06 +0100 Subject: [PATCH 14/22] Core: Enhance input sanitization and add XSS prevention in story file generation --- .../create-new-story-channel.ts | 18 +------- .../utils/get-new-story-file.test.ts | 36 +++++++++++++++ .../core-server/utils/get-new-story-file.ts | 7 ++- .../src/core-server/utils/safeString.test.ts | 46 +++++++++---------- code/core/src/core-server/utils/safeString.ts | 21 +++++++-- 5 files changed, 81 insertions(+), 47 deletions(-) diff --git a/code/core/src/core-server/server-channel/create-new-story-channel.ts b/code/core/src/core-server/server-channel/create-new-story-channel.ts index c524d7dc4da2..94967d7e3e17 100644 --- a/code/core/src/core-server/server-channel/create-new-story-channel.ts +++ b/code/core/src/core-server/server-channel/create-new-story-channel.ts @@ -14,7 +14,6 @@ import { telemetry } from 'storybook/internal/telemetry'; import type { CoreConfig, Options } from 'storybook/internal/types'; import { generateStoryFile } from '../utils/generate-story'; -import { safeJsString } from '../utils/safeString'; export function initCreateNewStoryChannel( channel: Channel, @@ -25,22 +24,7 @@ export function initCreateNewStoryChannel( channel.on( CREATE_NEW_STORYFILE_REQUEST, async (data: RequestData) => { - const { - componentFilePath, - componentExportName, - componentExportCount, - componentIsDefaultExport, - } = data.payload; - - const result = await generateStoryFile( - { - componentFilePath: safeJsString(componentFilePath), - componentExportName: safeJsString(componentExportName), - componentExportCount, - componentIsDefaultExport, - }, - options - ); + const result = await generateStoryFile(data.payload, options); if (result.success) { channel.emit(CREATE_NEW_STORYFILE_RESPONSE, { diff --git a/code/core/src/core-server/utils/get-new-story-file.test.ts b/code/core/src/core-server/utils/get-new-story-file.test.ts index 86f77e93ad9f..806b44a94cb1 100644 --- a/code/core/src/core-server/utils/get-new-story-file.test.ts +++ b/code/core/src/core-server/utils/get-new-story-file.test.ts @@ -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; + + export default meta; + + type Story = StoryObj; + + 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'); diff --git a/code/core/src/core-server/utils/get-new-story-file.ts b/code/core/src/core-server/utils/get-new-story-file.ts index 5abbeb10082c..ea48a9d68cd6 100644 --- a/code/core/src/core-server/utils/get-new-story-file.ts +++ b/code/core/src/core-server/utils/get-new-story-file.ts @@ -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( { @@ -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); @@ -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}` + ); } } diff --git a/code/core/src/core-server/utils/safeString.test.ts b/code/core/src/core-server/utils/safeString.test.ts index 020d2fc7bc54..ca275ff86574 100644 --- a/code/core/src/core-server/utils/safeString.test.ts +++ b/code/core/src/core-server/utils/safeString.test.ts @@ -1,33 +1,33 @@ import { describe, expect, it } from 'vitest'; -import { safeJsString } from './safeString'; +import { escapeForTemplate } from './safeString'; describe('safeString', () => { - it('should escape single quotes characters', () => { - expect( - safeJsString("./button.tsx'alert({ console.log('malicious code') })") - ).toMatchInlineSnapshot(`"./button.tsx\\'alert({ console.log(\\'malicious code\\') })"`); - }); + describe('escapeForTemplate', () => { + it('should escape backticks in template strings', () => { + expect(escapeForTemplate('button`s.tsx')).toBe('button\\`s.tsx'); + }); - it('should escape double quotes characters', () => { - expect( - safeJsString('./button.tsx"alert({ console.log("malicious code") })') - ).toMatchInlineSnapshot(`"./button.tsx\\"alert({ console.log(\\"malicious code\\") })"`); - }); + it('should escape dollar signs for template expressions', () => { + expect(escapeForTemplate('button$file.tsx')).toBe('button\\$file.tsx'); + }); - it('should escape backslashes characters', () => { - expect(safeJsString('const file = "\\nexports.ts"')).toMatchInlineSnapshot( - `"const file = \\"\\\\nexports.ts\\""` - ); - }); + it('should escape backslashes', () => { + expect(escapeForTemplate('button\\file.tsx')).toBe('button\\\\file.tsx'); + }); - it('should escape new line characters', () => { - expect(safeJsString('const file = "\nexports.ts"')).toMatchInlineSnapshot( - `"const file = \\"\\nexports.ts\\""` - ); - }); + it('should escape quotes or newlines', () => { + expect(escapeForTemplate("button's.tsx")).toBe("button\\'s.tsx"); + expect(escapeForTemplate('button"s.tsx')).toBe('button\\"s.tsx'); + expect(escapeForTemplate('button\ns.tsx')).toBe('button\\ns.tsx'); + }); + + it('should handle multiple special characters', () => { + expect(escapeForTemplate('button`${file}\\path.tsx')).toBe('button\\`\\${file}\\\\path.tsx'); + }); - it('should skip escaping if not needed', () => { - expect(safeJsString('./button.tsx')).toMatchInlineSnapshot(`"./button.tsx"`); + it('should preserve normal file paths', () => { + expect(escapeForTemplate('./src/components/Button.tsx')).toBe('./src/components/Button.tsx'); + }); }); }); diff --git a/code/core/src/core-server/utils/safeString.ts b/code/core/src/core-server/utils/safeString.ts index 03a4d2ff45be..6da305582f69 100644 --- a/code/core/src/core-server/utils/safeString.ts +++ b/code/core/src/core-server/utils/safeString.ts @@ -1,14 +1,25 @@ -/** Escape special characters in a string to make it safe for use in JavaScript strings. */ -export function safeJsString(str: string): string { - return str.replace(/['"\\\b\f\n\r\t`$]/g, (char) => { +/** + * 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(/[`$'"\\\n\r]/g, (char) => { const codes: Record = { + '`': '\\`', "'": "\\'", '"': '\\"', + $: '\\$', '\\': '\\\\', '\n': '\\n', '\r': '\\r', - '`': '\\`', - $: '\\$', }; return codes[char] || char; }); From 4d8237d24d835d7e9c08769f1ab6960f10d29a7f Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 24 Feb 2026 13:06:38 +0100 Subject: [PATCH 15/22] Enhance escapeForTemplate implementation --- .../src/core-server/utils/safeString.test.ts | 21 +++++++++++-------- code/core/src/core-server/utils/safeString.ts | 16 ++++---------- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/code/core/src/core-server/utils/safeString.test.ts b/code/core/src/core-server/utils/safeString.test.ts index ca275ff86574..0eb4559bb027 100644 --- a/code/core/src/core-server/utils/safeString.test.ts +++ b/code/core/src/core-server/utils/safeString.test.ts @@ -5,29 +5,32 @@ import { escapeForTemplate } from './safeString'; describe('safeString', () => { describe('escapeForTemplate', () => { it('should escape backticks in template strings', () => { - expect(escapeForTemplate('button`s.tsx')).toBe('button\\`s.tsx'); + expect(escapeForTemplate('button`s.tsx')).toMatchInlineSnapshot('"button\\`s.tsx"'); }); it('should escape dollar signs for template expressions', () => { - expect(escapeForTemplate('button$file.tsx')).toBe('button\\$file.tsx'); + expect(escapeForTemplate('button$file.tsx')).toMatchInlineSnapshot('"button\\$file.tsx"'); }); it('should escape backslashes', () => { - expect(escapeForTemplate('button\\file.tsx')).toBe('button\\\\file.tsx'); + expect(escapeForTemplate('button\\file.tsx')).toMatchInlineSnapshot('"button\\\\file.tsx"'); }); - it('should escape quotes or newlines', () => { - expect(escapeForTemplate("button's.tsx")).toBe("button\\'s.tsx"); - expect(escapeForTemplate('button"s.tsx')).toBe('button\\"s.tsx'); - expect(escapeForTemplate('button\ns.tsx')).toBe('button\\ns.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')).toBe('button\\`\\${file}\\\\path.tsx'); + expect(escapeForTemplate('button`${file}\\path.tsx')).toMatchInlineSnapshot( + `"button\\\`\\\${file}\\\\path.tsx"` + ); }); it('should preserve normal file paths', () => { - expect(escapeForTemplate('./src/components/Button.tsx')).toBe('./src/components/Button.tsx'); + expect(escapeForTemplate('./src/components/Button.tsx')).toMatchInlineSnapshot( + '"./src/components/Button.tsx"' + ); }); }); }); diff --git a/code/core/src/core-server/utils/safeString.ts b/code/core/src/core-server/utils/safeString.ts index 6da305582f69..c4bf8025e4dc 100644 --- a/code/core/src/core-server/utils/safeString.ts +++ b/code/core/src/core-server/utils/safeString.ts @@ -11,16 +11,8 @@ * ``` */ export function escapeForTemplate(str: string): string { - return str.replace(/[`$'"\\\n\r]/g, (char) => { - const codes: Record = { - '`': '\\`', - "'": "\\'", - '"': '\\"', - $: '\\$', - '\\': '\\\\', - '\n': '\\n', - '\r': '\\r', - }; - return codes[char] || char; - }); + return str + .replace(/\\/g, '\\\\') // Escape backslashes first + .replace(/(['"$`])/g, '\\$&') // Then escape quotes, dollar signs, and backticks + .replace(/[\n\r]/g, '\\$&'); // Then newlines } From 10d1e88911be83d5467d32ccd446d5499d00b8e5 Mon Sep 17 00:00:00 2001 From: storybook-bot <32066757+storybook-bot@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:31:09 +0000 Subject: [PATCH 16/22] Update CHANGELOG.md for v10.2.12 [skip ci] --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14dc6a61ecc7..256196a7e79a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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! From 2751ffbbd5a87da3017d23ee2aa0caa8857cfc2f Mon Sep 17 00:00:00 2001 From: yannbf Date: Tue, 24 Feb 2026 18:44:44 +0100 Subject: [PATCH 17/22] fix linting --- code/addons/pseudo-states/vitest.config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/code/addons/pseudo-states/vitest.config.ts b/code/addons/pseudo-states/vitest.config.ts index 54a7400f6fec..ba2124fc31aa 100644 --- a/code/addons/pseudo-states/vitest.config.ts +++ b/code/addons/pseudo-states/vitest.config.ts @@ -1,5 +1,6 @@ import { defineConfig, mergeConfig } from 'vitest/config'; -import { playwright } from '@vitest/browser-playwright' + +import { playwright } from '@vitest/browser-playwright'; import { vitestCommonConfig } from '../../vitest.shared'; From 6fc49d1456016bcaf5116b236cd9260a3dc47862 Mon Sep 17 00:00:00 2001 From: yannbf Date: Tue, 24 Feb 2026 18:45:44 +0100 Subject: [PATCH 18/22] use playwright image for unit tests --- scripts/ci/common-jobs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/common-jobs.ts b/scripts/ci/common-jobs.ts index a294206c1fed..4b029009fdc3 100644 --- a/scripts/ci/common-jobs.ts +++ b/scripts/ci/common-jobs.ts @@ -239,7 +239,7 @@ export const testsUnit_linux = defineJob( 'Tests (linux)', (workflowName) => ({ executor: { - name: 'sb_node_22_classic', + name: 'sb_playwright', class: 'large', }, steps: [ From 6d1e517ad4ebc6da74a17edf70178fc14256637c Mon Sep 17 00:00:00 2001 From: ia319 Date: Wed, 25 Feb 2026 02:04:25 +0800 Subject: [PATCH 19/22] fix(viewport): remove false default from normalizeGlobal in parseGlobals to restore isRotated fallback --- code/core/src/viewport/useViewport.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/code/core/src/viewport/useViewport.ts b/code/core/src/viewport/useViewport.ts index 0e96de08f505..7e1e3eea9b20 100644 --- a/code/core/src/viewport/useViewport.ts +++ b/code/core/src/viewport/useViewport.ts @@ -78,9 +78,9 @@ const parseGlobals = ( }; } - const global = normalizeGlobal(globals?.[PARAM_KEY], false); - const userGlobal = normalizeGlobal(userGlobals?.[PARAM_KEY], false); - const storyGlobal = normalizeGlobal(storyGlobals?.[PARAM_KEY], false); + const global = normalizeGlobal(globals?.[PARAM_KEY]); + const userGlobal = normalizeGlobal(userGlobals?.[PARAM_KEY]); + const storyGlobal = normalizeGlobal(storyGlobals?.[PARAM_KEY]); const storyHasViewport = PARAM_KEY in storyGlobals; // Story-level viewport globals override user globals for the current story. From 8e1bafbc9921290e5601ab1b8b9ba0505aeb392d Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 25 Feb 2026 10:36:16 +0100 Subject: [PATCH 20/22] Core: Avoid performance bottlenecks when infering args for recursive calls on DOM elemens --- .../modules/store/inferArgTypes.ts | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/code/core/src/preview-api/modules/store/inferArgTypes.ts b/code/core/src/preview-api/modules/store/inferArgTypes.ts index 04637f18ac59..f4e69dd6b3c8 100644 --- a/code/core/src/preview-api/modules/store/inferArgTypes.ts +++ b/code/core/src/preview-api/modules/store/inferArgTypes.ts @@ -6,7 +6,12 @@ import { dedent } from 'ts-dedent'; import { combineParameters } from './parameters'; -const inferType = (value: any, name: string, visited: Set): SBType => { +const inferType = ( + value: any, + name: string, + visited: Set, + cache: Map +): SBType => { const type = typeof value; switch (type) { case 'boolean': @@ -19,6 +24,12 @@ const inferType = (value: any, name: string, visited: Set): 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. @@ -29,25 +40,36 @@ const inferType = (value: any, name: string, visited: Set): 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 = (context) => { const { id, argTypes: userArgTypes = {}, initialArgs = {} } = context; + const cache = new Map(); 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, From dc787d2a80f3c6997a66d3ad8c43ff5e2e9524a6 Mon Sep 17 00:00:00 2001 From: yannbf Date: Wed, 25 Feb 2026 11:36:36 +0100 Subject: [PATCH 21/22] move tests to use stories for verification instead --- .../src/preview/rewriteStyleSheet.test.ts | 58 ------------------- .../src/stories/NestedRules.stories.tsx | 25 ++++++++ .../pseudo-states/src/stories/NestedRules.tsx | 7 +++ .../pseudo-states/src/stories/nested.css | 21 +++++++ code/addons/pseudo-states/vitest.config.ts | 8 --- scripts/ci/common-jobs.ts | 2 +- 6 files changed, 54 insertions(+), 67 deletions(-) create mode 100644 code/addons/pseudo-states/src/stories/NestedRules.stories.tsx create mode 100644 code/addons/pseudo-states/src/stories/NestedRules.tsx create mode 100644 code/addons/pseudo-states/src/stories/nested.css diff --git a/code/addons/pseudo-states/src/preview/rewriteStyleSheet.test.ts b/code/addons/pseudo-states/src/preview/rewriteStyleSheet.test.ts index 4560d17abc5c..c2381421640a 100644 --- a/code/addons/pseudo-states/src/preview/rewriteStyleSheet.test.ts +++ b/code/addons/pseudo-states/src/preview/rewriteStyleSheet.test.ts @@ -3,34 +3,6 @@ import { describe, expect, it } from 'vitest'; import { rewriteStyleSheet } from './rewriteStyleSheet'; import { splitSelectors } from './splitSelectors'; -function getNestedRule(rule: any, index: number) { - if (!('cssRules' in rule)) { - throw new Error('Cannot get nested rule, because the rule is not a CSSGroupingRule.', rule); - } - return (rule as CSSGroupingRule).cssRules[index]; -} - -function ensureCSSStyleRule(rule: any) { - if (!(rule instanceof CSSStyleRule)) { - throw new Error(`Rule is not a CSSStyleRule, but a ${rule.constructor.name}`); - } - return rule; -} - -function ensureCSSLayerBlockRule(rule: any) { - if (!(rule instanceof CSSLayerBlockRule)) { - throw new Error(`Rule is not a BlockLayerRule, but a ${rule.constructor.name}`); - } - return rule; -} - -function ensureCSSMediaRule(rule: any) { - if (!(rule instanceof CSSMediaRule)) { - throw new Error(`Rule is not a CSSMediaRule, but a ${rule.constructor.name}`); - } - return rule; -} - function splitRules(cssText: string): string[] { let ruleStart: number | undefined; let depth = 0; @@ -478,36 +450,6 @@ describe('rewriteStyleSheet', () => { expect(sheet.cssRules[3].getSelectors()).toContain('.pseudo-hover-all .test2'); }); - it('rewrites deeply nested rules', () => { - const styleSheet = new CSSStyleSheet(); - styleSheet.replaceSync( - `@layer utilities { - .hover\\:text-red-500 { - @media (hover: hover) { - &:hover { - color: var(--color-red-500); - } - } - } - }` - ); - - rewriteStyleSheet(styleSheet); - - const layerRule = ensureCSSLayerBlockRule(getNestedRule(styleSheet, 0)); - expect(layerRule.name).toBe('utilities'); - - const twSelector = ensureCSSStyleRule(getNestedRule(layerRule, 0)); - expect(twSelector.selectorText).toBe('.hover\\:text-red-500'); - - const media = ensureCSSMediaRule(getNestedRule(twSelector, 0)); - expect(media.conditionText).toBe('(hover: hover)'); - - const hover = ensureCSSStyleRule(getNestedRule(media, 0)); - expect(hover).toBeInstanceOf(CSSStyleRule); - expect(hover.selectorText).toBe('&:hover, &.pseudo-hover, .pseudo-hover-all &'); - }); - it('rewrites rules inside "@media"', () => { const sheet = new Sheet( `@media (max-width: 790px) { diff --git a/code/addons/pseudo-states/src/stories/NestedRules.stories.tsx b/code/addons/pseudo-states/src/stories/NestedRules.stories.tsx new file mode 100644 index 000000000000..3b52327a73f0 --- /dev/null +++ b/code/addons/pseudo-states/src/stories/NestedRules.stories.tsx @@ -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) => , +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +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)'); + // }, +}; diff --git a/code/addons/pseudo-states/src/stories/NestedRules.tsx b/code/addons/pseudo-states/src/stories/NestedRules.tsx new file mode 100644 index 000000000000..41a047caca9c --- /dev/null +++ b/code/addons/pseudo-states/src/stories/NestedRules.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +import './nested.css'; + +export const Button = (props: React.ButtonHTMLAttributes) => ( +