Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
## 10.2.8

- Builder-Vite: Use relative path for mocker entry in production builds - [#33792](https://github.com/storybookjs/storybook/pull/33792), thanks @DukeDeSouth!
- Telemetry: Add Expo metaframework - [#33783](https://github.com/storybookjs/storybook/pull/33783), thanks @copilot-swe-agent!
- Telemetry: Add init exit event - [#33773](https://github.com/storybookjs/storybook/pull/33773), thanks @valentinpalkovic!
- Telemetry: Add share events - [#33766](https://github.com/storybookjs/storybook/pull/33766), thanks @ndelangen!
- Test: Update event creation logic in user-event package - [#33787](https://github.com/storybookjs/storybook/pull/33787), thanks @valentinpalkovic!

## 10.2.7

- CSF: Fix cross-file story imports in csf-factories codemod - [#33723](https://github.com/storybookjs/storybook/pull/33723), thanks @yatishgoel!
Expand Down
5 changes: 2 additions & 3 deletions code/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,9 @@ module.exports = {
'import-x/no-named-as-default-member': 'warn',
'react/destructuring-assignment': 'warn',

// This warns about importing interfaces and types in a normal import, it's arguably better to import with the `type` prefix separate from the runtime imports,
// I leave this as a warning right now because we haven't really decided yet, and the codebase is riddled with errors if I set to 'error'.
// Our codebase is mostly TypeScript, and typescript will warn when imports are not found.
// It IS set to 'error' for JS files.
'import-x/named': 'warn',
'import-x/named': 'off',
},
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from 'vitest';

/**
* Unit-tests for the vite-inject-mocker plugin, focused on the `transformIndexHtml` hook which must
* emit a **relative** `src` during production builds (so Storybook artifacts load when hosted at
* non-root paths, e.g. GitHub Pages) and an **absolute** `src` during development (so Vite's
* dev-server `resolveId` can match it).
*
* @see https://github.com/storybookjs/storybook/issues/32428
*/

// We need to mock the import.meta.resolve call and node:url before
// importing the plugin, because the module resolves the mocker
// runtime path at import time.
vi.mock('node:url', () => ({
fileURLToPath: vi.fn(() => '/fake/mocker-runtime.js'),
}));

// Mock import.meta.resolve
vi.stubGlobal('import', { meta: { resolve: () => 'file:///fake/mocker-runtime.js' } });

// Dynamic import after mocks are set up
const { viteInjectMockerRuntime } = await import('./plugin.js');

function makeHtml(headAttrs = '') {
return `<!doctype html><html><head${headAttrs}><meta charset="utf-8" /></head><body></body></html>`;
}

describe('vite-inject-mocker plugin — transformIndexHtml', () => {
function createPlugin(command: 'build' | 'serve') {
const plugin = viteInjectMockerRuntime({ previewConfigPath: null }) as any;
// Simulate Vite calling configResolved
plugin.configResolved({ command } as any);
return plugin;
}

it('uses a relative path (./…) in build mode', () => {
const plugin = createPlugin('build');
const html = makeHtml();
const result = plugin.transformIndexHtml(html);

expect(result).toContain('src="./vite-inject-mocker-entry.js"');
expect(result).not.toContain('src="/vite-inject-mocker-entry.js"');
});

it('uses an absolute path (/…) in dev mode', () => {
const plugin = createPlugin('serve');
const html = makeHtml();
const result = plugin.transformIndexHtml(html);

expect(result).toContain('src="/vite-inject-mocker-entry.js"');
// Ensure it's not the relative form
expect(result).not.toContain('src="./vite-inject-mocker-entry.js"');
});

it('injects the script tag right after <head>', () => {
const plugin = createPlugin('build');
const html = makeHtml();
const result = plugin.transformIndexHtml(html);

const headIndex = result.indexOf('<head>');
const scriptIndex = result.indexOf('<script type="module"');
expect(scriptIndex).toBeGreaterThan(headIndex);
expect(scriptIndex).toBe(headIndex + '<head>'.length);
});

it('handles <head> tags with attributes', () => {
const plugin = createPlugin('build');
const html = makeHtml(' lang="en"');
const result = plugin.transformIndexHtml(html);

expect(result).toContain('src="./vite-inject-mocker-entry.js"');
expect(result).toContain('<head lang="en"><script type="module"');
});

it('returns undefined when <head> is missing', () => {
const plugin = createPlugin('build');
const result = plugin.transformIndexHtml('<html><body></body></html>');
expect(result).toBeUndefined();
});

it('preserves the rest of the HTML unchanged', () => {
const plugin = createPlugin('build');
const html = makeHtml();
const result = plugin.transformIndexHtml(html);

// Remove the injected script to verify the rest is intact
const cleaned = result.replace(
'<script type="module" src="./vite-inject-mocker-entry.js"></script>',
''
);
expect(cleaned).toBe(html);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,12 @@ export const viteInjectMockerRuntime = (options: {
const headTag = html.match(/<head[^>]*>/);

if (headTag) {
const entryCode = `<script type="module" src="${ENTRY_PATH}"></script>`;
// Use a relative path for production builds so the script loads
// correctly when artifacts are hosted at non-root paths (e.g.,
// GitHub Pages subdirectories). In dev mode, the absolute path
// is required so Vite's dev server can match it in resolveId.
const src = viteConfig.command === 'build' ? `.${ENTRY_PATH}` : ENTRY_PATH;
const entryCode = `<script type="module" src="${src}"></script>`;
const headTagIndex = html.indexOf(headTag[0]);
const newHtml =
html.slice(0, headTagIndex + headTag[0].length) +
Expand Down
7 changes: 7 additions & 0 deletions code/core/src/core-events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ enum events {
OPEN_IN_EDITOR_RESPONSE = 'openInEditorResponse',
// Emitted when the manager UI sets up a focus trap
MANAGER_INERT_ATTRIBUTE_CHANGED = 'managerInertAttributeChanged',

SHARE_STORY_LINK = 'shareStoryLink',
SHARE_ISOLATE_MODE = 'shareIsolateMode',
SHARE_POPOVER_OPENED = 'sharePopoverOpened',
}

// Enables: `import Events from ...`
Expand Down Expand Up @@ -167,6 +171,9 @@ export const {
OPEN_IN_EDITOR_REQUEST,
OPEN_IN_EDITOR_RESPONSE,
MANAGER_INERT_ATTRIBUTE_CHANGED,
SHARE_STORY_LINK,
SHARE_ISOLATE_MODE,
SHARE_POPOVER_OPENED,
} = events;

export * from './data/create-new-story';
Expand Down
4 changes: 2 additions & 2 deletions code/core/src/core-server/presets/common-preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { initCreateNewStoryChannel } from '../server-channel/create-new-story-ch
import { initFileSearchChannel } from '../server-channel/file-search-channel';
import { initGhostStoriesChannel } from '../server-channel/ghost-stories-channel';
import { initOpenInEditorChannel } from '../server-channel/open-in-editor-channel';
import { initPreviewInitializedChannel } from '../server-channel/preview-initialized-channel';
import { initTelemetryChannel } from '../server-channel/telemetry-channel';
import { initializeChecklist } from '../utils/checklist';
import { defaultFavicon, defaultStaticDirs } from '../utils/constants';
import { initializeSaveStory } from '../utils/save-story/save-story';
Expand Down Expand Up @@ -264,7 +264,7 @@ export const experimental_serverChannel = async (
initCreateNewStoryChannel(channel, options, coreOptions);
initGhostStoriesChannel(channel, options, coreOptions);
initOpenInEditorChannel(channel, options, coreOptions);
initPreviewInitializedChannel(channel, options, coreOptions);
initTelemetryChannel(channel, options);

return channel;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { makePayload } from './preview-initialized-channel';
import { makePayload } from './telemetry-channel';

describe('makePayload', () => {
beforeEach(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import type { Channel } from 'storybook/internal/channels';
import { PREVIEW_INITIALIZED } from 'storybook/internal/core-events';
import {
PREVIEW_INITIALIZED,
SHARE_ISOLATE_MODE,
SHARE_POPOVER_OPENED,
SHARE_STORY_LINK,
} from 'storybook/internal/core-events';
import { type InitPayload, telemetry } from 'storybook/internal/telemetry';
import { type CacheEntry, getLastEvents } from 'storybook/internal/telemetry';
import { getSessionId } from 'storybook/internal/telemetry';
import type { CoreConfig, Options } from 'storybook/internal/types';
import type { Options } from 'storybook/internal/types';

export const makePayload = (
userAgent: string,
Expand All @@ -24,13 +29,9 @@ export const makePayload = (
return payload;
};

export function initPreviewInitializedChannel(
channel: Channel,
options: Options,
_coreConfig: CoreConfig
) {
channel.on(PREVIEW_INITIALIZED, async ({ userAgent }) => {
if (!options.disableTelemetry) {
export function initTelemetryChannel(channel: Channel, options: Options) {
if (!options.disableTelemetry) {
channel.on(PREVIEW_INITIALIZED, async ({ userAgent }) => {
try {
const sessionId = await getSessionId();
const lastEvents = await getLastEvents();
Expand All @@ -40,9 +41,16 @@ export function initPreviewInitializedChannel(
const payload = makePayload(userAgent, lastInit, sessionId);
telemetry('preview-first-load', payload);
}
} catch (e) {
// do nothing
}
}
});
} catch {}
});
channel.on(SHARE_POPOVER_OPENED, async () => {
telemetry('share', { action: 'popover-opened' });
});
channel.on(SHARE_STORY_LINK, async () => {
telemetry('share', { action: 'story-link-copied' });
});
channel.on(SHARE_ISOLATE_MODE, async () => {
telemetry('share', { action: 'isolate-mode-opened' });
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { global } from '@storybook/global';
import type { StoryObj } from '@storybook/react-vite';

import { ManagerContext } from 'storybook/manager-api';
import { expect, screen, waitFor } from 'storybook/test';
import { expect, fn, screen, waitFor } from 'storybook/test';

import { shareTool } from './share';

Expand All @@ -15,6 +15,7 @@ const managerContext = {
refId: undefined,
},
api: {
emit: fn().mockName('api::emit'),
getShortcutKeys: () => ({
copyStoryLink: ['alt', 'shift', 'l'],
openInIsolation: ['alt', 'shift', 'i'],
Expand Down
15 changes: 14 additions & 1 deletion code/core/src/manager/components/preview/tools/share.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';

import { Button, PopoverProvider, TooltipLinkList } from 'storybook/internal/components';
import {
SHARE_ISOLATE_MODE,
SHARE_POPOVER_OPENED,
SHARE_STORY_LINK,
} from 'storybook/internal/core-events';
import type { Addon_BaseType } from 'storybook/internal/types';

import { global } from '@storybook/global';
Expand Down Expand Up @@ -71,6 +76,10 @@ const ShareMenu = React.memo(function ShareMenu({
const copyStoryLink = shortcutKeys?.copyStoryLink;
const openInIsolation = shortcutKeys?.openInIsolation;

useEffect(() => {
api.emit(SHARE_POPOVER_OPENED);
}, [api]);

const links = useMemo(() => {
const copyTitle = copied ? 'Copied!' : 'Copy story link';
const originHrefs = api.getStoryHrefs(storyId, { base: 'origin', refId });
Expand All @@ -84,6 +93,7 @@ const ShareMenu = React.memo(function ShareMenu({
icon: <LinkIcon />,
right: enableShortcuts ? <Shortcut keys={copyStoryLink} /> : null,
onClick: () => {
api.emit(SHARE_STORY_LINK, originHrefs.managerHref);
copy(originHrefs.managerHref);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
Expand All @@ -94,6 +104,9 @@ const ShareMenu = React.memo(function ShareMenu({
title: 'Open in isolation mode',
icon: <ShareAltIcon />,
right: enableShortcuts ? <Shortcut keys={openInIsolation} /> : null,
onClick: () => {
api.emit(SHARE_ISOLATE_MODE, originHrefs.previewHref);
},
href: originHrefs.previewHref,
target: '_blank',
rel: 'noopener noreferrer',
Expand Down
3 changes: 3 additions & 0 deletions code/core/src/manager/globals/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,9 @@ export default {
'SET_WHATS_NEW_CACHE',
'SHARED_STATE_CHANGED',
'SHARED_STATE_SET',
'SHARE_ISOLATE_MODE',
'SHARE_POPOVER_OPENED',
'SHARE_STORY_LINK',
'STORIES_COLLAPSE_ALL',
'STORIES_EXPAND_ALL',
'STORY_ARGS_UPDATED',
Expand Down
1 change: 1 addition & 0 deletions code/core/src/telemetry/storybook-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const metaFrameworks = {
'@tanstack/react-router': 'tanstack-react',
'@react-router/dev': 'react-router',
'@remix-run/dev': 'remix',
expo: 'expo',
} as Record<string, string>;

export const sanitizeAddonName = (name: string) => {
Expand Down
2 changes: 2 additions & 0 deletions code/core/src/telemetry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type EventType =
| 'scaffolded-empty'
| 'browser'
| 'canceled'
| 'exit'
| 'error'
| 'error-metadata'
| 'version-update'
Expand All @@ -42,6 +43,7 @@ export type EventType =
| 'migrate'
| 'preview-first-load'
| 'doctor'
| 'share'
| 'ghost-stories';
export interface Dependency {
version: string | undefined;
Expand Down
Loading