diff --git a/.chromatic-fc/main.js b/.chromatic-fc/main.mjs
similarity index 69%
rename from .chromatic-fc/main.js
rename to .chromatic-fc/main.mjs
index 3e18290db84..997be771c99 100644
--- a/.chromatic-fc/main.js
+++ b/.chromatic-fc/main.mjs
@@ -1,7 +1,7 @@
-module.exports = {
+export default {
framework: {
- name: "storybook-react-parcel",
+ name: 'storybook-react-parcel',
options: {},
},
stories: [
@@ -15,5 +15,11 @@ module.exports = {
typescript: {
check: false,
reactDocgen: false
+ },
+ core: {
+ disableWhatsNewNotifications: true
+ },
+ features: {
+ sidebarOnboardingChecklist: false
}
};
diff --git a/.chromatic/main.js b/.chromatic/main.mjs
similarity index 69%
rename from .chromatic/main.js
rename to .chromatic/main.mjs
index a9f9a574e15..09ffdcf94fe 100644
--- a/.chromatic/main.js
+++ b/.chromatic/main.mjs
@@ -1,7 +1,7 @@
-module.exports = {
+export default {
framework: {
- name: "storybook-react-parcel",
+ name: 'storybook-react-parcel',
options: {},
},
stories: [
@@ -15,5 +15,11 @@ module.exports = {
typescript: {
check: false,
reactDocgen: false
+ },
+ core: {
+ disableWhatsNewNotifications: true
+ },
+ features: {
+ sidebarOnboardingChecklist: false
}
};
diff --git a/.storybook-s2/custom-addons/provider/preset.ts b/.storybook-s2/custom-addons/provider/preset.ts
new file mode 100644
index 00000000000..53595a81a19
--- /dev/null
+++ b/.storybook-s2/custom-addons/provider/preset.ts
@@ -0,0 +1,17 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+/**
+ * Preset that registers our manager entry (register.tsx) with Storybook.
+ * The manager builder will bundle register.tsx and transpile JSX there;
+ * this file stays JS-free so Node can load it as a preset.
+ * In plain english, this is needed so that register can be a tsx file.
+ * @see https://github.com/storybookjs/addon-kit
+ * @see https://storybook.js.org/docs/addons/writing-presets#managerentries
+ */
+export const managerEntries = (existing: string[] = []) => [
+ ...existing,
+ path.join(__dirname, "register.tsx"),
+];
diff --git a/.storybook-s2/custom-addons/provider/register.js b/.storybook-s2/custom-addons/provider/register.tsx
similarity index 87%
rename from .storybook-s2/custom-addons/provider/register.js
rename to .storybook-s2/custom-addons/provider/register.tsx
index e334a7ad444..b06338db428 100644
--- a/.storybook-s2/custom-addons/provider/register.js
+++ b/.storybook-s2/custom-addons/provider/register.tsx
@@ -4,8 +4,9 @@ import {locales} from '../../constants';
import React, {useEffect, useState} from 'react';
function ProviderFieldSetter({api}) {
- let localeParam = api.getQueryParam('providerSwitcher-locale') || undefined;
- let [values, setValues] = useState({locale: localeParam});
+ let [values, setValues] = useState(() => ({
+ locale: api.getQueryParam('providerSwitcher-locale') || undefined,
+ }));
let channel = addons.getChannel();
let onLocaleChange = (e) => {
let newValue = e.target.value || undefined;
@@ -23,19 +24,19 @@ function ProviderFieldSetter({api}) {
return () => {
channel.removeListener('rsp/ready-for-update', storySwapped);
};
- });
+ }, [values, channel]);
useEffect(() => {
api.setQueryParams({
'providerSwitcher-locale': values.locale || ''
});
- });
+ }, [api, values.locale]);
return (
-
diff --git a/.storybook-s2/main.ts b/.storybook-s2/main.ts
index ec83a5ebf84..2e1eceb9929 100644
--- a/.storybook-s2/main.ts
+++ b/.storybook-s2/main.ts
@@ -1,4 +1,5 @@
import type { StorybookConfig } from "storybook/internal/types";
+import { fileURLToPath } from "node:url";
// const excludedProps = new Set([
// 'id',
@@ -14,13 +15,15 @@ import type { StorybookConfig } from "storybook/internal/types";
// 'onInput'
// ]);
+const localAddon = (rel: string) => fileURLToPath(import.meta.resolve(rel));
+
const config: StorybookConfig = {
stories: [
'./docs/*.mdx',
"../packages/@react-spectrum/s2/stories/*.stories.@(js|jsx|mjs|ts|tsx)",
],
addons: [
- './custom-addons/provider/register',
+ localAddon('./custom-addons/provider/preset.ts'),
// "@storybook/addon-styling-webpack",
"@storybook/addon-docs",
"@vueless/storybook-dark-mode",
@@ -32,6 +35,9 @@ const config: StorybookConfig = {
},
core: {
disableWhatsNewNotifications: true
+ },
+ features: {
+ sidebarOnboardingChecklist: false
}
// typescript: {
// reactDocgen: 'react-docgen-typescript',
diff --git a/.storybook-s2/preview.tsx b/.storybook-s2/preview.tsx
index ceba3e0fa2c..d0081fe3f90 100644
--- a/.storybook-s2/preview.tsx
+++ b/.storybook-s2/preview.tsx
@@ -1,16 +1,31 @@
import '@react-spectrum/s2/page.css';
import { themes } from 'storybook/theming';
-import { DARK_MODE_EVENT_NAME } from '@vueless/storybook-dark-mode';
-import { store } from '@vueless/storybook-dark-mode/dist/esm/Tool';
+import { DARK_MODE_EVENT_NAME, useDarkMode } from '@vueless/storybook-dark-mode';
import { addons } from 'storybook/preview-api';
-import React, { useEffect, useState } from 'react';
+import React from 'react';
import {withProviderSwitcher} from './custom-addons/provider';
import {DocsContainer, Controls, Description, Primary, Stories, Subtitle, Title} from '@storybook/addon-docs/blocks';
import './global.css';
+const DARK_MODE_STORAGE_KEY = 'sb-addon-themes-3';
+
+function getInitialColorScheme(): 'dark' | 'light' {
+ if (typeof window === 'undefined') return 'light';
+ try {
+ const stored = window.localStorage.getItem(DARK_MODE_STORAGE_KEY);
+ if (stored) {
+ const { current } = JSON.parse(stored);
+ return current === 'dark' ? 'dark' : 'light';
+ }
+ } catch {}
+ return 'light';
+}
+
const channel = addons.getChannel();
-document.documentElement.dataset.colorScheme = store().current === 'dark' ? 'dark' : 'light';
-channel.on(DARK_MODE_EVENT_NAME, isDark => document.documentElement.dataset.colorScheme = isDark ? 'dark' : 'light');
+document.documentElement.dataset.colorScheme = getInitialColorScheme();
+channel.on(DARK_MODE_EVENT_NAME, (isDark: boolean) => {
+ document.documentElement.dataset.colorScheme = isDark ? 'dark' : 'light';
+});
/** @type { import('@storybook/react').Preview } */
const preview = {
@@ -21,12 +36,8 @@ const preview = {
},
docs: {
container: (props) => {
- let [dark, setDark] = useState(store().current === 'dark');
- useEffect(() => {
- channel.on(DARK_MODE_EVENT_NAME, setDark);
- return () => channel.removeListener(DARK_MODE_EVENT_NAME, setDark);
- }, []);
- var style = getComputedStyle(document.body)
+ const dark = useDarkMode();
+ var style = getComputedStyle(document.body);
return
;
},
codePanel: true,
diff --git a/.storybook/custom-addons/descriptions/register.js b/.storybook/custom-addons/descriptions/manager.js
similarity index 100%
rename from .storybook/custom-addons/descriptions/register.js
rename to .storybook/custom-addons/descriptions/manager.js
diff --git a/.storybook/custom-addons/provider/index.js b/.storybook/custom-addons/provider/index.js
index fef66393200..9859e00acd2 100644
--- a/.storybook/custom-addons/provider/index.js
+++ b/.storybook/custom-addons/provider/index.js
@@ -1,6 +1,7 @@
import React, {useEffect, useState} from 'react';
import {addons} from 'storybook/preview-api';
import {makeDecorator} from 'storybook/preview-api';
+import {useDarkMode} from '@vueless/storybook-dark-mode';
import {Provider} from '@react-spectrum/provider';
import {expressThemes, themes, defaultTheme} from '../../constants';
@@ -17,10 +18,13 @@ function ProviderUpdater(props) {
let expressParam = params.get("providerSwitcher-express") || undefined;
let [expressValue, setExpress] = useState(expressParam === 'true');
let [storyReady, setStoryReady] = useState(window.parent === window || window.parent !== window.top); // reduce content flash because it takes a moment to get the provider details
+ let isDark = useDarkMode();
// Typically themes are provided with both light + dark, and both scales.
// To build our selector to see all themes, we need to hack it a bit.
let theme = (expressValue ? expressThemes : themes)[themeValue || 'light'] || defaultTheme;
- let colorScheme = themeValue && themeValue.replace(/est$/, '');
+ // When the providerSwitcher theme is set explicitly use it, otherwise follow
+ // the storybook-dark-mode toolbar toggle.
+ let colorScheme = themeValue ? themeValue.replace(/est$/, '') : (isDark ? 'dark' : 'light');
useEffect(() => {
let channel = addons.getChannel();
let providerUpdate = (event) => {
diff --git a/.storybook/custom-addons/provider/register.js b/.storybook/custom-addons/provider/manager.js
similarity index 100%
rename from .storybook/custom-addons/provider/register.js
rename to .storybook/custom-addons/provider/manager.js
diff --git a/.storybook/custom-addons/scrolling/register.js b/.storybook/custom-addons/scrolling/manager.js
similarity index 100%
rename from .storybook/custom-addons/scrolling/register.js
rename to .storybook/custom-addons/scrolling/manager.js
diff --git a/.storybook/custom-addons/strictmode/register.js b/.storybook/custom-addons/strictmode/manager.js
similarity index 100%
rename from .storybook/custom-addons/strictmode/register.js
rename to .storybook/custom-addons/strictmode/manager.js
diff --git a/.storybook/custom-addons/theme/register.js b/.storybook/custom-addons/theme/manager.js
similarity index 100%
rename from .storybook/custom-addons/theme/register.js
rename to .storybook/custom-addons/theme/manager.js
diff --git a/.storybook/main.js b/.storybook/main.mjs
similarity index 65%
rename from .storybook/main.js
rename to .storybook/main.mjs
index 6d2ed3805fe..2638bdee8cd 100644
--- a/.storybook/main.js
+++ b/.storybook/main.mjs
@@ -1,5 +1,8 @@
+import { fileURLToPath } from "node:url";
-module.exports = {
+const localAddon = (rel) => fileURLToPath(import.meta.resolve(rel));
+
+export default {
stories: [
'../packages/@{react-aria,react-stately,spectrum-icons}/*/stories/*.stories.{js,jsx,ts,tsx}',
'../packages/@react-spectrum/!(s2)/stories/*.stories.{js,jsx,ts,tsx}',
@@ -13,11 +16,11 @@ module.exports = {
'storybook/actions',
'@storybook/addon-a11y',
'@vueless/storybook-dark-mode',
- './custom-addons/provider/register.js',
- './custom-addons/descriptions/register.js',
- './custom-addons/theme/register.js',
- './custom-addons/strictmode/register.js',
- './custom-addons/scrolling/register.js'
+ localAddon('./custom-addons/provider'),
+ localAddon('./custom-addons/descriptions'),
+ localAddon('./custom-addons/theme'),
+ localAddon('./custom-addons/strictmode'),
+ localAddon('./custom-addons/scrolling'),
],
typescript: {
@@ -32,5 +35,9 @@ module.exports = {
core: {
disableWhatsNewNotifications: true
+ },
+
+ features: {
+ sidebarOnboardingChecklist: false
}
};
diff --git a/.storybook/preview.js b/.storybook/preview.js
index ac74152dba0..a849ff52fb4 100644
--- a/.storybook/preview.js
+++ b/.storybook/preview.js
@@ -1,4 +1,6 @@
+import {addons} from 'storybook/preview-api';
import {configureActions} from 'storybook/actions';
+import {DARK_MODE_EVENT_NAME} from '@vueless/storybook-dark-mode';
import React from 'react';
import {withProviderSwitcher} from './custom-addons/provider';
import {withScrollingSwitcher} from './custom-addons/scrolling';
@@ -10,6 +12,29 @@ configureActions({
depth: 2,
});
+// Reflect storybook-dark-mode state on the document root so global CSS / consumers
+// can react. Mirrors the .storybook-s2 setup. Initial value comes from the addon's
+// localStorage key so the very first paint is correct.
+const DARK_MODE_STORAGE_KEY = 'sb-addon-themes-3';
+function getInitialColorScheme() {
+ if (typeof window === 'undefined') return 'light';
+ try {
+ const stored = window.localStorage.getItem(DARK_MODE_STORAGE_KEY);
+ if (stored) {
+ const {current} = JSON.parse(stored);
+ return current === 'dark' ? 'dark' : 'light';
+ }
+ } catch (e) {}
+ return 'light';
+}
+
+if (typeof document !== 'undefined') {
+ document.documentElement.dataset.colorScheme = getInitialColorScheme();
+ addons.getChannel().on(DARK_MODE_EVENT_NAME, (isDark) => {
+ document.documentElement.dataset.colorScheme = isDark ? 'dark' : 'light';
+ });
+}
+
export const parameters = {
options: {
storySort: (a, b) => {
diff --git a/jest.config.js b/jest.config.js
index 259debb58ac..43c2ed2c432 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -214,10 +214,13 @@ module.exports = {
]
},
- // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
- // transformIgnorePatterns: [
- // "/node_modules/"
- // ],
+ // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation.
+ // Storybook 10 ships pure ESM in `storybook` and `@storybook/*`, so we need to let
+ // @swc/jest transform them instead of the default behavior of skipping all node_modules.
+ transformIgnorePatterns: [
+ '/node_modules/(?!(?:storybook|@storybook)/)',
+ '\\.pnp\\.[^\\/]+$'
+ ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
diff --git a/jest.ssr.config.js b/jest.ssr.config.js
index 9ce8bf7152e..fa243d5ef98 100644
--- a/jest.ssr.config.js
+++ b/jest.ssr.config.js
@@ -69,4 +69,11 @@ module.exports = {
}
]
},
+
+ // Storybook 10 ships pure ESM in `storybook` and `@storybook/*`, so we need
+ // to let @swc/jest transform them instead of skipping all of node_modules.
+ transformIgnorePatterns: [
+ '/node_modules/(?!(?:storybook|@storybook)/)',
+ '\\.pnp\\.[^\\/]+$'
+ ]
};
diff --git a/package.json b/package.json
index 97f8b6f5396..475ad990764 100644
--- a/package.json
+++ b/package.json
@@ -119,12 +119,11 @@
"@react-spectrum/s2-icon-builder": "workspace:^",
"@spectrum-css/component-builder": "workspace:^",
"@spectrum-css/vars": "^2.3.0",
- "@storybook/addon-a11y": "^9.0.18",
- "@storybook/addon-docs": "^9.0.18",
- "@storybook/addon-jest": "^9.0.18",
- "@storybook/addon-themes": "^9.0.18",
- "@storybook/react": "^9.0.18",
- "@storybook/test-runner": "^0.22.0",
+ "@storybook/addon-a11y": "^10.0.0",
+ "@storybook/addon-docs": "^10.0.0",
+ "@storybook/addon-themes": "^10.0.0",
+ "@storybook/react": "^10.0.0",
+ "@storybook/test-runner": "^0.24.0",
"@stylistic/eslint-plugin-ts": "^2.9.0",
"@swc/core": "^1.3.36",
"@swc/jest": "^0.2.36",
@@ -139,7 +138,7 @@
"@vitejs/plugin-react": "^5.1.4",
"@vitest/browser-playwright": "^4.0.17",
"@vitest/browser-preview": "^4.0.17",
- "@vueless/storybook-dark-mode": "^9.0.6",
+ "@vueless/storybook-dark-mode": "^10.0.0",
"@yarnpkg/types": "^4.0.0",
"autoprefixer": "^9.6.0",
"axe-playwright": "^1.1.11",
@@ -199,7 +198,7 @@
"rimraf": "^6.0.1",
"shadow-dom-testing-library": "^1.13.1",
"sharp": "^0.33.5",
- "storybook": "^9.0.18",
+ "storybook": "^10.0.0",
"storybook-react-parcel": "workspace:^",
"tailwind-variants": "patch:tailwind-variants@npm%3A0.3.1#~/.yarn/patches/tailwind-variants-npm-0.3.1-48888516de.patch",
"tailwindcss": "^4.0.0",
diff --git a/packages/dev/parcel-resolver-storybook/StorybookResolver.ts b/packages/dev/parcel-resolver-storybook/StorybookResolver.ts
index ee4140d3f3a..d5c4a682e75 100644
--- a/packages/dev/parcel-resolver-storybook/StorybookResolver.ts
+++ b/packages/dev/parcel-resolver-storybook/StorybookResolver.ts
@@ -4,11 +4,19 @@ const reactVersion = require("react-dom/package.json").version;
import { default as NodeResolver } from "@parcel/node-resolver-core";
// @ts-ignore
import { isGlob, glob, normalizeSeparators, relativePath } from '@parcel/utils';
+import { globalsNameReferenceMap } from 'storybook/internal/preview/globals';
const REACT_MAJOR_VERSION = parseInt(reactVersion.split('.')[0], 10);
module.exports = new Resolver({
async resolve({ dependency, options, specifier, pipeline, logger }) {
+ if (specifier in globalsNameReferenceMap) {
+ return {
+ filePath: __dirname + "/globals.js",
+ code: `module.exports = ${globalsNameReferenceMap[specifier]};`
+ };
+ }
+
// Workaround for interop issue
if (specifier === "react-dom/client" && REACT_MAJOR_VERSION < 18) {
return {
diff --git a/packages/dev/parcel-transformer-storybook/package.json b/packages/dev/parcel-transformer-storybook/package.json
index c03c4cca846..177477d961b 100644
--- a/packages/dev/parcel-transformer-storybook/package.json
+++ b/packages/dev/parcel-transformer-storybook/package.json
@@ -16,7 +16,7 @@
"@parcel/plugin": "^2.16.3",
"@parcel/source-map": "^2.1.1",
"react-docgen-typescript": "^2.2.2",
- "storybook": "^9.0.18",
+ "storybook": "^10.0.0",
"typescript": "^5.5.0"
},
"scripts": {
diff --git a/packages/dev/storybook-builder-parcel/gen-iframe-modern.js b/packages/dev/storybook-builder-parcel/gen-iframe-modern.mjs
similarity index 60%
rename from packages/dev/storybook-builder-parcel/gen-iframe-modern.js
rename to packages/dev/storybook-builder-parcel/gen-iframe-modern.mjs
index 29ead8152cb..4af836d0ceb 100644
--- a/packages/dev/storybook-builder-parcel/gen-iframe-modern.js
+++ b/packages/dev/storybook-builder-parcel/gen-iframe-modern.mjs
@@ -1,39 +1,18 @@
-const { normalizeStories } = require("storybook/internal/common");
+import { normalizeStories } from "storybook/internal/common";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
-const TEMPLATE = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
+const TEMPLATE_PATH = fileURLToPath(new URL("./templates/iframe.html", import.meta.url));
-module.exports.generateIframeModern = async function generateIframeModern(options) {
+export async function generateIframeModern(options) {
+ const TEMPLATE = readFileSync(TEMPLATE_PATH, "utf8");
const { configType, features, presets, serverChannelUrl, title } = options;
const frameworkOptions = await presets.apply("frameworkOptions");
const headHtmlSnippet = await presets.apply("previewHead");
const bodyHtmlSnippet = await presets.apply("previewBody");
const logLevel = await presets.apply("logLevel", undefined);
const docsOptions = await presets.apply("docs");
+ const tagsOptions = await presets.apply("tags", {});
const coreOptions = await presets.apply("core");
const stories = normalizeStories(
@@ -63,7 +42,8 @@ module.exports.generateIframeModern = async function generateIframeModern(option
.replace(`'[FEATURES HERE]'`, JSON.stringify(features || {}))
.replace(`'[STORIES HERE]'`, JSON.stringify(stories || {}))
.replace(`'[DOCS_OPTIONS HERE]'`, JSON.stringify(docsOptions || {}))
+ .replace(`'[TAGS_OPTIONS HERE]'`, JSON.stringify(tagsOptions || {}))
.replace(`'[SERVER_CHANNEL_URL HERE]'`, JSON.stringify(serverChannelUrl))
.replace("", headHtmlSnippet || "")
.replace("", bodyHtmlSnippet || "");
-};
+}
diff --git a/packages/dev/storybook-builder-parcel/gen-preview-modern.js b/packages/dev/storybook-builder-parcel/gen-preview-modern.js
deleted file mode 100644
index ea6f30004ec..00000000000
--- a/packages/dev/storybook-builder-parcel/gen-preview-modern.js
+++ /dev/null
@@ -1,160 +0,0 @@
-const path = require("path");
-const {
- loadPreviewOrConfigFile,
- normalizeStories,
- stripAbsNodeModulesPath,
-} = require("storybook/internal/common");
-const {relativePath} = require('@parcel/utils');
-
-module.exports.generatePreviewModern = async function generatePreviewModern(
- options,
- generatedEntries
-) {
- const { presets, configDir } = options;
-
- const previewAnnotations = await presets.apply(
- "previewAnnotations",
- [],
- options
- );
- const relativePreviewAnnotations = [
- ...previewAnnotations.map(processPreviewAnnotation),
- relativePath(
- generatedEntries,
- loadPreviewOrConfigFile({ configDir })
- ),
- ].filter(Boolean);
-
- /**
- * This code is largely taken from https://github.com/storybookjs/storybook/blob/d1195cbd0c61687f1720fefdb772e2f490a46584/lib/builder-webpack4/src/preview/virtualModuleModernEntry.js.handlebars
- * Some small tweaks were made to `getProjectAnnotations` (since `import()` needs to be resolved asynchronously)
- * and the HMR implementation has been tweaked to work with Vite.
- * @todo Inline variable and remove `noinspection`
- */
- const code = `
- import { setup } from 'storybook/internal/preview/runtime';
-
- setup();
-
- import { createBrowserChannel } from 'storybook/internal/channels';
- import { addons } from 'storybook/internal/preview-api';
-
- const channel = createBrowserChannel({ page: 'preview' });
- addons.setChannel(channel);
- window.__STORYBOOK_ADDONS_CHANNEL__ = channel;
-
- if (window.CONFIG_TYPE === 'DEVELOPMENT'){
- window.__STORYBOOK_SERVER_CHANNEL__ = channel;
- }
-
- import { composeConfigs, PreviewWeb } from 'storybook/internal/preview-api';
- import { isPreview } from 'storybook/internal/csf';
-
- ${await generateImportFnScriptCode(options, generatedEntries)}
-
- const getProjectAnnotations = async () => {
- const configs = await Promise.all([${relativePreviewAnnotations
- .map((previewAnnotation) => `import('${previewAnnotation}')`)
- .join(",\n")}])
- return composeConfigs(configs);
- }
-
-
- window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations);
-
- window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore;
-
-
- if (import.meta.hot) {
- import.meta.hot.hot.accept(() => {
- // importFn has changed so we need to patch the new one in
- window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn });
-
- // getProjectAnnotations has changed so we need to patch the new one in
- window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations });
- });
- }
- `;
- // ${generateHMRHandler(frameworkName)};
- return code;
-};
-
-function processPreviewAnnotation(annotationPath) {
- // If entry is an object, take the first, which is the
- // bare (non-absolute) specifier.
- // This is so that webpack can use an absolute path, and
- // continue supporting super-addons in pnp/pnpm without
- // requiring them to re-export their sub-addons as we do
- // in addon-essentials.
- if (typeof annotationPath === "object") {
- return annotationPath.bare;
- }
- // resolve relative paths into absolute paths, but don't resolve "bare" imports
- if (annotationPath?.startsWith("./") || annotationPath?.startsWith("../")) {
- return /*slash*/ annotationPath.resolve(annotationPath);
- }
- // This should not occur, since we use `.filter(Boolean)` prior to
- // calling this function, but this makes typescript happy
- if (!annotationPath) {
- throw new Error("Could not determine path for previewAnnotation");
- }
-
- // For addon dependencies that use require.resolve(), we need to convert to a bare path
- // so that vite will process it as a dependency (cjs -> esm, etc).
- if (annotationPath.includes("node_modules")) {
- return stripAbsNodeModulesPath(annotationPath);
- }
-
- return /*slash*/ annotationPath;
-}
-
-/**
- * This file is largely based on https://github.com/storybookjs/storybook/blob/d1195cbd0c61687f1720fefdb772e2f490a46584/lib/core-common/src/utils/to-importFn.ts
- */
-
-/**
- * This function takes an array of stories and creates a mapping between the stories' relative paths
- * to the working directory and their dynamic imports. The import is done in an asynchronous function
- * to delay loading. It then creates a function, `importFn(path)`, which resolves a path to an import
- * function and this is called by Storybook to fetch a story dynamically when needed.
- * @param stories An array of absolute story paths.
- */
-async function toImportFn(stories, generatedEntries) {
- const entries = stories.map(glob => {
- return `...import(${JSON.stringify('story:' + btoa(relativePath(generatedEntries, glob)))})`;
- });
-
- return `
- const importers = {
- ${entries.join(',\n')}
- };
-
- async function importFn(path) {
- return importers[path]();
- }
- `;
-}
-
-async function generateImportFnScriptCode(options, generatedEntries) {
- // First we need to get an array of stories and their absolute paths.
- let stories = await listStories(options);
-
- // We can then call toImportFn to create a function that can be used to load each story dynamically.
- return (await toImportFn(stories, generatedEntries)).trim();
-}
-
-async function listStories(options) {
- return (
- await Promise.all(
- normalizeStories(await options.presets.apply("stories", [], options), {
- configDir: options.configDir,
- workingDir: options.configDir,
- }).map(({ directory, files }) => {
- let pattern = path.join(directory, files);
- return path.isAbsolute(pattern)
- ? pattern
- : path.join(options.configDir, pattern);
- })
- )
- ).reduce((carry, stories) => carry.concat(stories), []);
-}
diff --git a/packages/dev/storybook-builder-parcel/gen-preview-modern.mjs b/packages/dev/storybook-builder-parcel/gen-preview-modern.mjs
new file mode 100644
index 00000000000..6be1b54d8db
--- /dev/null
+++ b/packages/dev/storybook-builder-parcel/gen-preview-modern.mjs
@@ -0,0 +1,228 @@
+import path from "node:path";
+import { Buffer } from "node:buffer";
+import { createRequire } from "node:module";
+import {
+ loadPreviewOrConfigFile,
+ normalizeStories,
+} from "storybook/internal/common";
+
+// @parcel/utils is CJS-only; use createRequire to load it from ESM
+const _require = createRequire(import.meta.url);
+const { relativePath } = _require("@parcel/utils");
+
+// base64 helper for story: pipeline specifiers (globalThis.btoa exists in Node >=18)
+const btoa = (s) => Buffer.from(s, "utf8").toString("base64");
+
+/**
+ * Strip the absolute path prefix up to and including `node_modules`, returning
+ * a bare specifier the bundler can resolve. (Storybook 10 no longer exports
+ * this helper from `storybook/internal/common`, so we inline our own.)
+ *
+ * Background: addons advertise their preview entries as absolute
+ * `node_modules/@storybook/foo/dist/preview.js` paths via the
+ * `previewAnnotations` preset, but each package's `exports` map only exposes
+ * the bare subpath (e.g. `./preview`) -- not the dist file. Parcel's resolver
+ * honors `exports`, so we must rewrite the absolute path into a bare specifier
+ * before handing it off (see also `toPackageExportSpecifier`).
+ */
+function stripAbsNodeModulesPath(absPath) {
+ const sep = path.sep;
+ const splits = absPath.split(`node_modules${sep}`);
+ const last = splits[splits.length - 1];
+ return path.posix.normalize(last.replace(/\\/g, "/"));
+}
+
+/**
+ * Convert a bare path like @storybook/react/dist/entry-preview-argtypes.js to the
+ * package export subpath @storybook/react/entry-preview-argtypes so bundlers (Parcel)
+ * resolve via package.json "exports" instead of raw dist files.
+ * Paths like package/dist/index.js become package (main export), not package/index.
+ */
+function toPackageExportSpecifier(barePath) {
+ const normalized = barePath.replace(/\\/g, "/");
+ const distMatch = normalized.match(/^(@?[^/]+(?:\/[^/]+)?)\/dist\/(.+)\.js$/);
+ if (distMatch) {
+ const pkg = distMatch[1];
+ const subpath = distMatch[2];
+ // package/dist/index.js -> package (main export), not package/index
+ if (subpath === "index") {
+ return pkg;
+ }
+ return `${pkg}/${subpath}`;
+ }
+ return normalized;
+}
+
+/**
+ * Generate the addon setup module (similar to Vite's VIRTUAL_ADDON_SETUP_FILE
+ * in code/builders/builder-vite/src/codegen-modern-iframe-script.ts).
+ */
+function generateSetupAddons() {
+ return `
+import { addons } from 'storybook/preview-api';
+import { createBrowserChannel } from 'storybook/internal/channels';
+
+const channel = createBrowserChannel({ page: 'preview' });
+addons.setChannel(channel);
+window.__STORYBOOK_ADDONS_CHANNEL__ = channel;
+
+if (window.CONFIG_TYPE === 'DEVELOPMENT') {
+ window.__STORYBOOK_SERVER_CHANNEL__ = channel;
+}
+`.trim();
+}
+
+async function generatePreviewModern(
+ options,
+ generatedEntries
+) {
+ const { presets, configDir } = options;
+
+ const previewAnnotations = await presets.apply(
+ "previewAnnotations",
+ [],
+ options
+ );
+ const relativePreviewAnnotations = [
+ ...previewAnnotations.map(processPreviewAnnotation),
+ relativePath(
+ generatedEntries,
+ loadPreviewOrConfigFile({ configDir })
+ ),
+ ].filter(Boolean);
+
+ const importFnCode = await generateImportFnScriptCode(options, generatedEntries);
+
+ /**
+ * Main preview module loaded directly by iframe.html as
+
+
+
+
+
+
+
+
+