Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a7982fd
Move getStoryHref into addon-docs, simplify logic and deprecate origi…
ghengeveld Dec 23, 2025
2a864fd
Add getStoryHrefs API and use it for preview iframe URL
ghengeveld Dec 23, 2025
628f670
Add openInIsolation shortcut and update ShareMenu to utilize new API …
ghengeveld Dec 23, 2025
b9b78dd
Use getStoryHrefs for ref iframes as well, and fix types
ghengeveld Dec 23, 2025
2800dc6
Handle review comments
ghengeveld Dec 23, 2025
72174ff
nit: use same shortcut in story as elsewhere
Sidnioulz Dec 24, 2025
8ea95a9
rewrite getStoryHref docs util to use Web APIs
Sidnioulz Dec 24, 2025
7bf5c42
nit: clarify nesting options in getStoryHrefs
Sidnioulz Dec 24, 2025
e5d6628
support encoded parameters in getStoryHrefs
Sidnioulz Dec 24, 2025
d474f5f
Consistently use 'disableSnapshot' rather than 'disable' for Chromati…
ghengeveld Dec 23, 2025
2f4abff
Added disable feature for intercations.
jeevikar14 Dec 15, 2025
73f0331
fix: resolve TypeScript errors in interactions disable feature
jeevikar14 Dec 15, 2025
200d137
refactor: extract disable logic for proper unit testing
jeevikar14 Dec 16, 2025
5d19b63
fix: prettier formatting
jeevikar14 Dec 17, 2025
c1e08b6
fix: import with js extension for ESM compat
yue4u Dec 22, 2025
f105b08
Update CHANGELOG.md for v10.1.11 [skip ci]
storybook-bot Dec 29, 2025
daa4d6a
Write changelog for 10.2.0-alpha.10 [skip ci]
storybook-bot Dec 29, 2025
9f8f895
Bump version from "10.2.0-alpha.9" to "10.2.0-alpha.10" [skip ci]
storybook-bot Dec 29, 2025
424e746
Docs: Mention Vite publicDir interference
Sidnioulz Dec 29, 2025
9a6b80a
Docs: Mention Vite publicDir interference
Sidnioulz Dec 30, 2025
1439a24
Core: Fix internal yarn build command
Sidnioulz Dec 30, 2025
fc1d08e
DX: Document yarn build options
Sidnioulz Dec 30, 2025
c9af058
Remove obsolete comments
Sidnioulz Dec 30, 2025
fd3bbc5
Use correct exit code for failed CLI run
Sidnioulz Dec 30, 2025
be9b72b
Core: Add try-catch for cross-origin access in Storybook hooks
ndelangen Dec 31, 2025
0a48d27
UI: Keep preview frame stable in overall layout
Sidnioulz Dec 30, 2025
bf55dfb
Disable open in isolation keyboard shortcut for docs pages
ghengeveld Jan 5, 2026
381ba17
Document api methods
yannbf Nov 18, 2025
2e9e276
Add missing openInEditor SubAPI typedefs
ghengeveld Jan 5, 2026
16761a3
Apply suggestions
ghengeveld Jan 5, 2026
1a9134a
Add missing guard
ghengeveld Jan 5, 2026
6f12897
Fix story parameters
ghengeveld Jan 5, 2026
d7dc2d5
Fix TS issue
ghengeveld Jan 5, 2026
e518754
Merge branch 'next' into isolation-mode-shortcut
ghengeveld Jan 5, 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
1 change: 0 additions & 1 deletion code/addons/docs/src/blocks/components/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,6 @@ export const Preview: FC<PreviewProps> = ({
zoom={(z: number) => setScale(scale * z)}
resetZoom={() => setScale(1)}
storyId={!isLoading && childProps ? getStoryId(childProps, context) : undefined}
baseUrl="./iframe.html"
/>
)}
<ZoomContext.Provider value={{ scale }}>
Expand Down
8 changes: 3 additions & 5 deletions code/addons/docs/src/blocks/components/Story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,15 @@
import type { FunctionComponent } from 'react';
import React, { useEffect, useRef, useState } from 'react';

import { ErrorFormatter, Loader, getStoryHref } from 'storybook/internal/components';
import { ErrorFormatter, Loader } from 'storybook/internal/components';
import type { DocsContextProps, PreparedStory } from 'storybook/internal/types';

import { styled } from 'storybook/theming';

import { getStoryHref } from '../getStoryHref';
import { IFrame } from './IFrame';
import { ZoomContext } from './ZoomContext';

const { PREVIEW_URL } = globalThis;
const BASE_URL = PREVIEW_URL || 'iframe.html';

interface CommonProps {
story: PreparedStory;
inline: boolean;
Expand Down Expand Up @@ -98,7 +96,7 @@ const IFrameStory: FunctionComponent<IFrameStoryProps> = ({ story, height = '500
key="iframe"
id={`iframe--${story.id}`}
title={story.name}
src={getStoryHref(BASE_URL, story.id, { viewMode: 'story' })}
src={getStoryHref(story.id, { viewMode: 'story' })}
allowFullScreen
scale={scale}
style={{
Expand Down
17 changes: 5 additions & 12 deletions code/addons/docs/src/blocks/components/Toolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import type { FC, SyntheticEvent } from 'react';
import React from 'react';

import { Button, Toolbar as SharedToolbar, getStoryHref } from 'storybook/internal/components';
import { Button, Toolbar as SharedToolbar } from 'storybook/internal/components';

import { ShareAltIcon, ZoomIcon, ZoomOutIcon, ZoomResetIcon } from '@storybook/icons';

import { styled } from 'storybook/theming';

import { getStoryHref } from '../getStoryHref';

interface ZoomProps {
zoom: (val: number) => void;
resetZoom: () => void;
}

interface EjectProps {
storyId?: string;
baseUrl?: string;
}

interface BarProps {
Expand Down Expand Up @@ -52,14 +53,7 @@ const IconPlaceholder = styled.div(({ theme }) => ({
animation: `${theme.animation.glow} 1.5s ease-in-out infinite`,
}));

export const Toolbar: FC<ToolbarProps> = ({
isLoading,
storyId,
baseUrl,
zoom,
resetZoom,
...rest
}) => (
export const Toolbar: FC<ToolbarProps> = ({ isLoading, storyId, zoom, resetZoom, ...rest }) => (
<AbsoluteBar innerStyle={{ gap: 4, paddingInline: 7, justifyContent: 'space-between' }} {...rest}>
<Wrapper key="left">
{isLoading ? (
Expand Down Expand Up @@ -111,7 +105,6 @@ export const Toolbar: FC<ToolbarProps> = ({
<IconPlaceholder />
</Wrapper>
) : (
baseUrl &&
storyId && (
<Wrapper key="right">
<Button
Expand All @@ -121,7 +114,7 @@ export const Toolbar: FC<ToolbarProps> = ({
key="opener"
ariaLabel="Open canvas in new tab"
>
<a href={getStoryHref(baseUrl, storyId)} target="_blank" rel="noopener noreferrer">
<a href={getStoryHref(storyId)} target="_blank" rel="noopener noreferrer">
<ShareAltIcon />
</a>
</Button>
Expand Down
17 changes: 17 additions & 0 deletions code/addons/docs/src/blocks/getStoryHref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Only for internal use in addon-docs code, because the parent util in `core` cannot be imported.
* Unlike the parent util, this one only returns the preview URL.
*/
export const getStoryHref = (storyId: string, additionalParams: Record<string, string> = {}) => {
Comment thread
ndelangen marked this conversation as resolved.
const baseUrl = globalThis.PREVIEW_URL || 'iframe.html';
const [url, paramsStr] = baseUrl.split('?');
const params = new URLSearchParams(paramsStr || '');

Object.entries(additionalParams).forEach(([key, value]) => {
params.set(key, value);
});

params.set('id', storyId);

return `${url}?${params.toString()}`;
};
6 changes: 6 additions & 0 deletions code/core/src/components/components/utils/getStoryHref.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { deprecate } from 'storybook/internal/client-logger';

function parseQuery(queryString: string) {
const query: Record<string, string> = {};
const pairs = queryString.split('&');
Expand All @@ -9,11 +11,15 @@ function parseQuery(queryString: string) {
return query;
}

/** @deprecated Use the api.getStoryHrefs method instead (from `storybook/manager-api`) */
export const getStoryHref = (
baseUrl: string,
storyId: string,
additionalParams: Record<string, string> = {}
) => {
deprecate(
'getStoryHref is deprecated and will be removed in Storybook 11, use the api.getStoryHrefs method instead'
);
const [url, paramsStr] = baseUrl.split('?');
const params = paramsStr
? {
Expand Down
16 changes: 15 additions & 1 deletion code/core/src/manager-api/modules/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export interface API_Shortcuts {
expandAll: API_KeyCollection;
remount: API_KeyCollection;
openInEditor: API_KeyCollection;
openInIsolation: API_KeyCollection;
copyStoryLink: API_KeyCollection;
// TODO: bring this back once we want to add shortcuts for this
// copyStoryName: API_KeyCollection;
Expand Down Expand Up @@ -152,6 +153,7 @@ export const defaultShortcuts: API_Shortcuts = Object.freeze({
expandAll: [controlOrMetaKey(), 'shift', 'ArrowDown'],
remount: ['alt', 'R'],
openInEditor: ['alt', 'shift', 'E'],
openInIsolation: ['alt', 'shift', 'I'],
copyStoryLink: ['alt', 'shift', 'L'],
// TODO: bring this back once we want to add shortcuts for this
// copyStoryName: ['alt', 'shift', 'C'],
Expand Down Expand Up @@ -247,6 +249,8 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => {
const {
ui: { enableShortcuts },
storyId,
refId,
viewMode,
} = store.getState();
if (!enableShortcuts) {
return;
Expand Down Expand Up @@ -397,6 +401,13 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => {
}
break;
}
case 'openInIsolation': {
if (storyId && viewMode === 'story') {
const { previewHref } = fullAPI.getStoryHrefs(storyId, { refId });
window.open(previewHref, '_blank', 'noopener,noreferrer');
}
break;
}
Comment thread
ghengeveld marked this conversation as resolved.
// TODO: bring this back once we want to add shortcuts for this
// case 'copyStoryName': {
// const storyData = fullAPI.getCurrentStoryData();
Expand All @@ -406,7 +417,10 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => {
// break;
// }
case 'copyStoryLink': {
copy(window.location.href);
if (storyId) {
const { managerHref } = fullAPI.getStoryHrefs(storyId, { refId });
copy(managerHref);
}
break;
Comment thread
ghengeveld marked this conversation as resolved.
}
default:
Expand Down
104 changes: 98 additions & 6 deletions code/core/src/manager-api/modules/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,16 @@ import {
} from 'storybook/internal/core-events';
import { buildArgsParam, queryFromLocation } from 'storybook/internal/router';
import type { NavigateOptions } from 'storybook/internal/router';
import type { API_Layout, API_UI, Args } from 'storybook/internal/types';
import type { API_Layout, API_UI, API_ViewMode, Args } from 'storybook/internal/types';

import { global } from '@storybook/global';

import { dequal as deepEqual } from 'dequal';
import { stringify } from 'picoquery';

import type { ModuleArgs, ModuleFn } from '../lib/types';
import { defaultLayoutState } from './layout';

const { window: globalWindow } = global;

export interface SubState {
customQueryParams: QueryParams;
}
Expand All @@ -33,6 +32,24 @@ const parseBoolean = (value: string) => {
return undefined;
};

const parseSerializedParam = (param: string) =>
Object.fromEntries(
param
.split(';')
.map((pair) => pair.split(':'))
// Encoding values ensures we don't break already encoded args/globals but also don't encode our own special characters like ; and :.
.map(([key, value]) => [key, encodeURIComponent(value)])
.filter(([key, value]) => key && value)
);
Comment on lines +35 to +43

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle colons in serialized parameter values.

split(':') splits on every colon occurrence. If a value contains colons (e.g., "theme:dark:blue"), the destructuring [key, value] only captures the first two segments, discarding the rest. This could cause data loss when merging args or globals that have colon-separated values.

🔎 Proposed fix using split with limit
 const parseSerializedParam = (param: string) =>
   Object.fromEntries(
     param
       .split(';')
-      .map((pair) => pair.split(':'))
+      .map((pair) => pair.split(':', 2))
       .filter(([key, value]) => key && value)
   );

This limits the split to at most 2 parts, preserving colons in the value.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const parseSerializedParam = (param: string) =>
Object.fromEntries(
param
.split(';')
.map((pair) => pair.split(':'))
.filter(([key, value]) => key && value)
);
const parseSerializedParam = (param: string) =>
Object.fromEntries(
param
.split(';')
.map((pair) => pair.split(':', 2))
.filter(([key, value]) => key && value)
);
🤖 Prompt for AI Agents
In code/core/src/manager-api/modules/url.ts around lines 35 to 41, the parsing
logic uses pair.split(':') which splits on every colon and loses parts when
values contain colons; change the split to limit to two parts (e.g.,
pair.split(':', 2)) so the first segment is the key and the remainder (including
any colons) is the value, keep the existing filter for truthy key/value and
preserve Object.fromEntries usage.


const mergeSerializedParams = (params: string, extraParams: string) => {
const pairs = parseSerializedParam(params);
const extra = parseSerializedParam(extraParams);
return Object.entries({ ...pairs, ...extra })
.map(([key, value]) => `${key}:${value}`)
.join(';');
};

// Initialize the state based on the URL.
// NOTE:
// Although we don't change the URL when you change the state, we do support setting initial state
Expand Down Expand Up @@ -121,6 +138,33 @@ export interface SubAPI {
* @returns {void}
*/
navigateUrl: (url: string, options: NavigateOptions) => void;
/**
* Get the manager and preview hrefs for a story.
*
* @param {string} storyId - The ID of the story to get the URL for.
* @param {Object} options - Options for the URL.
* @param {string} [options.base] - Return an absolute href based on the current origin or network
* address.
* @param {boolean} [options.inheritArgs] - Inherit args from the current URL. If storyId matches
* current story, inheritArgs defaults to true.
* @param {boolean} [options.inheritGlobals] - Inherit globals from the current URL. Defaults to
* true.
* @param {QueryParams} [options.queryParams] - Query params to add to the URL.
* @param {string} [options.refId] - ID of the ref to get the URL for (for composed Storybooks)
* @param {string} [options.viewMode] - The view mode to use, defaults to 'story'.
* @returns {Object} Manager and preview hrefs for the story.
*/
getStoryHrefs(
storyId: string,
options?: {
base?: 'origin' | 'network';
inheritArgs?: boolean;
inheritGlobals?: boolean;
queryParams?: QueryParams;
refId?: string;
viewMode?: API_ViewMode;
}
): { managerHref: string; previewHref: string };
/**
* Get the value of a query parameter from the current URL.
*
Expand Down Expand Up @@ -183,6 +227,54 @@ export const init: ModuleFn<SubAPI, SubState> = (moduleArgs) => {
};

const api: SubAPI = {
getStoryHrefs(storyId, options = {}) {
const { id: currentStoryId, refId: currentRefId } = fullAPI.getCurrentStoryData() ?? {};
const isCurrentStory = storyId === currentStoryId && options.refId === currentRefId;

const { customQueryParams, location, refs } = store.getState();
const {
base,
inheritArgs = isCurrentStory,
inheritGlobals = true,
queryParams = {},
refId,
viewMode = 'story',
} = options;

if (refId && !refs[refId]) {
throw new Error(`Invalid refId: ${refId}`);
}

const originAddress = global.window.location.origin + location.pathname;
const networkAddress = global.STORYBOOK_NETWORK_ADDRESS ?? originAddress;
const managerBase =
base === 'origin' ? originAddress : base === 'network' ? networkAddress : location.pathname;
const previewBase = refId
? refs[refId].url + '/iframe.html'
: global.PREVIEW_URL || `${managerBase}iframe.html`;
Comment thread
ghengeveld marked this conversation as resolved.
Comment thread
Sidnioulz marked this conversation as resolved.

const refParam = refId ? `&refId=${encodeURIComponent(refId)}` : '';
const { args = '', globals = '', ...otherParams } = queryParams;
let argsParam = inheritArgs
? mergeSerializedParams(customQueryParams?.args ?? '', args)
: args;
let globalsParam = inheritGlobals
? mergeSerializedParams(customQueryParams?.globals ?? '', globals)
: globals;
let customParams = stringify(otherParams, {
nesting: true,
nestingSyntax: 'js',
});

argsParam = argsParam && `&args=${argsParam}`;
globalsParam = globalsParam && `&globals=${globalsParam}`;
customParams = customParams && `&${customParams}`;

return {
managerHref: `${managerBase}?path=/${viewMode}/${refId ? `${refId}_` : ''}${storyId}${argsParam}${globalsParam}${customParams}`,
previewHref: `${previewBase}?id=${storyId}&viewMode=${viewMode}${refParam}${argsParam}${refId ? '' : globalsParam}${customParams}`,
Comment on lines +269 to +275

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

URL-encode serialized parameters and path segments.

The serialized argsParam and globalsParam (containing characters like ;, :) are concatenated directly into query strings without URL encoding, which can break URLs if values contain reserved characters. Additionally, refId and storyId in the managerHref path (line 269) are not URL-encoded, though refId is correctly encoded when used as a query parameter at line 254.

🔎 Proposed fix to add URL encoding
-  argsParam = argsParam && `&args=${argsParam}`;
-  globalsParam = globalsParam && `&globals=${globalsParam}`;
+  argsParam = argsParam && `&args=${encodeURIComponent(argsParam)}`;
+  globalsParam = globalsParam && `&globals=${encodeURIComponent(globalsParam)}`;
   customParams = customParams && `&${customParams}`;

   return {
-    managerHref: `${managerBase}?path=/${viewMode}/${refId ? `${refId}_` : ''}${storyId}${argsParam}${globalsParam}${customParams}`,
+    managerHref: `${managerBase}?path=/${viewMode}/${refId ? `${encodeURIComponent(refId)}_` : ''}${encodeURIComponent(storyId)}${argsParam}${globalsParam}${customParams}`,
     previewHref: `${previewBase}?id=${storyId}&viewMode=${viewMode}${refParam}${argsParam}${refId ? '' : globalsParam}${customParams}`,
   };

Note: Verify that stringify() from picoquery already handles encoding for customParams.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
argsParam = argsParam && `&args=${argsParam}`;
globalsParam = globalsParam && `&globals=${globalsParam}`;
customParams = customParams && `&${customParams}`;
return {
managerHref: `${managerBase}?path=/${viewMode}/${refId ? `${refId}_` : ''}${storyId}${argsParam}${globalsParam}${customParams}`,
previewHref: `${previewBase}?id=${storyId}&viewMode=${viewMode}${refParam}${argsParam}${refId ? '' : globalsParam}${customParams}`,
argsParam = argsParam && `&args=${encodeURIComponent(argsParam)}`;
globalsParam = globalsParam && `&globals=${encodeURIComponent(globalsParam)}`;
customParams = customParams && `&${customParams}`;
return {
managerHref: `${managerBase}?path=/${viewMode}/${refId ? `${encodeURIComponent(refId)}_` : ''}${encodeURIComponent(storyId)}${argsParam}${globalsParam}${customParams}`,
previewHref: `${previewBase}?id=${storyId}&viewMode=${viewMode}${refParam}${argsParam}${refId ? '' : globalsParam}${customParams}`,

};
},
getQueryParam(key) {
const { customQueryParams } = store.getState();
return customQueryParams ? customQueryParams[key] : undefined;
Expand Down Expand Up @@ -253,11 +345,11 @@ export const init: ModuleFn<SubAPI, SubState> = (moduleArgs) => {

let handleOrId: any;
provider.channel?.on(STORY_ARGS_UPDATED, () => {
if ('requestIdleCallback' in globalWindow) {
if ('requestIdleCallback' in global.window) {
if (handleOrId) {
globalWindow.cancelIdleCallback(handleOrId);
global.window.cancelIdleCallback(handleOrId);
}
handleOrId = globalWindow.requestIdleCallback(updateArgsParam, { timeout: 1000 });
handleOrId = global.window.requestIdleCallback(updateArgsParam, { timeout: 1000 });
} else {
if (handleOrId) {
clearTimeout(handleOrId);
Expand Down
1 change: 1 addition & 0 deletions code/core/src/manager-api/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export type API = addons.SubAPI &
version.SubAPI &
url.SubAPI &
whatsnew.SubAPI &
openInEditor.SubAPI &
Other;

interface Other {
Expand Down
Loading
Loading