Skip to content
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
15 changes: 15 additions & 0 deletions code/addons/docs/src/blocks/getStoryHref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// 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> = {}) => {
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 */
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
13 changes: 12 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,7 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => {
const {
ui: { enableShortcuts },
storyId,
refId,
} = store.getState();
if (!enableShortcuts) {
return;
Expand Down Expand Up @@ -397,6 +400,13 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => {
}
break;
}
case 'openInIsolation': {
if (storyId) {
const { previewHref } = fullAPI.getStoryHrefs(storyId, { refId });
window.open(previewHref, '_blank', 'noopener,noreferrer');
}
break;
}
// TODO: bring this back once we want to add shortcuts for this
// case 'copyStoryName': {
// const storyData = fullAPI.getCurrentStoryData();
Expand All @@ -406,7 +416,8 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => {
// break;
// }
case 'copyStoryLink': {
copy(window.location.href);
const { managerHref } = fullAPI.getStoryHrefs(storyId, { refId });
copy(managerHref);
break;
}
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)
);

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`;

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
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
Loading