Skip to content

Commit 4889efd

Browse files
authored
Merge pull request #31968 from storybookjs/jeppe/fix-yarn-pnp
Core: Fix Yarn PnP support
2 parents 9b9ae7b + d4ec962 commit 4889efd

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+1457
-97
lines changed

.circleci/config.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,32 @@ jobs:
865865
name: Run Cypress CT tests
866866
command: yarn cypress
867867
working_directory: test-storybooks/portable-stories-kitchen-sink/<< parameters.directory >>
868+
test-yarn-pnp:
869+
executor:
870+
name: sb_playwright
871+
class: medium
872+
steps:
873+
- git-shallow-clone/checkout_advanced:
874+
clone_options: "--depth 1 --verbose"
875+
- attach_workspace:
876+
at: .
877+
- node/install:
878+
node-version: "22.17.0"
879+
- run:
880+
name: Install dependencies
881+
command: yarn install --no-immutable
882+
working_directory: test-storybooks/yarn-pnp
883+
environment:
884+
YARN_ENABLE_IMMUTABLE_INSTALLS: false
885+
# Disabled due to Jest not working in the ESM-only paradigm. Will require some recipe to make it work
886+
- run:
887+
name: Build Storybook
888+
command: yarn build-storybook
889+
working_directory: test-storybooks/yarn-pnp
890+
- run:
891+
name: Run Vitest tests
892+
command: yarn vitest run
893+
working_directory: test-storybooks/yarn-pnp
868894
- report-workflow-on-failure
869895
workflows:
870896
docs:
@@ -944,6 +970,9 @@ workflows:
944970
matrix:
945971
parameters:
946972
directory: ["react", "vue3", "nextjs", "svelte"]
973+
- test-yarn-pnp:
974+
requires:
975+
- build
947976
# TODO: reenable once we find out the source of flakyness
948977
# - test-runner-dev:
949978
# requires:
@@ -1015,6 +1044,9 @@ workflows:
10151044
- test-init-features:
10161045
requires:
10171046
- build
1047+
- test-yarn-pnp:
1048+
requires:
1049+
- build
10181050
# TODO: don't forget to reenable this
10191051
# - bench-sandboxes:
10201052
# parallelism: 5
@@ -1110,6 +1142,9 @@ workflows:
11101142
# --smoke-test is not supported for the angular builder right now
11111143
# - "angular-cli"
11121144
- "lit-vite-ts"
1145+
- test-yarn-pnp:
1146+
requires:
1147+
- build
11131148
# TODO: don't forget to reenable this
11141149
# - bench-sandboxes:
11151150
# parallelism: 5

code/core/src/common/js-package-manager/Yarn2Proxy.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,16 @@ export class Yarn2Proxy extends JsPackageManager {
156156

157157
if (pnpapiPath) {
158158
try {
159-
const pnpApi = await import(pnpapiPath);
159+
/*
160+
This is a rather fragile way to access Yarn's PnP API, essentially manually loading it.
161+
The proper way to do this would be to just do await import('pnpapi'),
162+
as documented at https://yarnpkg.com/advanced/pnpapi#requirepnpapi
163+
164+
However the 'pnpapi' module is only injected when the Node process is started via Yarn,
165+
which is not always the case for us, because we spawn child processes directly with Node,
166+
eg. when running automigrations.
167+
*/
168+
const { default: pnpApi } = await import(pnpapiPath);
160169

161170
const resolvedPath = pnpApi.resolveToUnqualified(
162171
packageName,
@@ -180,7 +189,7 @@ export class Yarn2Proxy extends JsPackageManager {
180189

181190
return crossFs.readJsonSync(virtualPath);
182191
} catch (error: any) {
183-
if (error.code !== 'MODULE_NOT_FOUND') {
192+
if (error.code !== 'ERR_MODULE_NOT_FOUND') {
184193
console.error('Error while fetching package version in Yarn PnP mode:', error);
185194
}
186195
return null;

code/core/src/common/presets.test.ts

Lines changed: 43 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { normalize } from 'node:path';
1+
import path, { join, normalize, relative } from 'node:path';
2+
import { fileURLToPath, pathToFileURL, resolve } from 'node:url';
23

34
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
45

@@ -24,40 +25,35 @@ vi.mock('storybook/internal/node-logger', () => ({
2425

2526
vi.mock('../shared/utils/module', () => ({
2627
importModule: vi.fn(),
28+
safeResolveModule: vi.fn(({ specifier }) => {
29+
const KNOWN_FILES = [
30+
'@storybook/react',
31+
'storybook/actions/manager',
32+
'./local/preset',
33+
'./local/addons',
34+
'/absolute/preset',
35+
'/absolute/addons',
36+
'@storybook/addon-docs',
37+
'@storybook/addon-cool',
38+
'@storybook/addon-docs/preset',
39+
'@storybook/addon-essentials',
40+
'@storybook/addon-knobs/manager',
41+
'@storybook/addon-knobs/register',
42+
'@storybook/addon-notes/register-panel',
43+
'@storybook/preset-create-react-app',
44+
'@storybook/preset-typescript',
45+
'addon-bar/preset.js',
46+
'addon-bar',
47+
'addon-baz/register.js',
48+
'addon-foo/register.js',
49+
];
50+
if (KNOWN_FILES.includes(specifier)) {
51+
return specifier;
52+
}
53+
return undefined;
54+
}),
2755
}));
2856

29-
vi.mock('mlly', () => {
30-
const KNOWN_FILES = [
31-
'@storybook/react',
32-
'storybook/actions/manager',
33-
'./local/preset',
34-
'./local/addons',
35-
'/absolute/preset',
36-
'/absolute/addons',
37-
'@storybook/addon-docs',
38-
'@storybook/addon-cool',
39-
'@storybook/addon-docs/preset',
40-
'@storybook/addon-essentials',
41-
'@storybook/addon-knobs/manager',
42-
'@storybook/addon-knobs/register',
43-
'@storybook/addon-notes/register-panel',
44-
'@storybook/preset-create-react-app',
45-
'@storybook/preset-typescript',
46-
'addon-bar/preset.js',
47-
'addon-bar',
48-
'addon-baz/register.js',
49-
'addon-foo/register.js',
50-
];
51-
52-
return {
53-
resolvePathSync: vi.fn((name: string) => {
54-
if (KNOWN_FILES.includes(name)) {
55-
return name;
56-
}
57-
throw new Error(`Could not resolve ${name}`);
58-
}),
59-
};
60-
});
6157
const mockedResolveUtils = vi.mocked(resolveUtils);
6258

6359
describe('presets', () => {
@@ -485,32 +481,20 @@ describe('loadPreset', () => {
485481
beforeEach(() => {
486482
vi.spyOn(logger, 'warn');
487483
mockedResolveUtils.importModule.mockImplementation(async (path: string) => {
488-
if (path === '@storybook/react') {
489-
return {};
490-
}
491-
if (path === '@storybook/preset-typescript') {
492-
return {};
493-
}
494-
if (path === '@storybook/addon-docs/preset') {
495-
return {};
496-
}
497-
if (path === 'addon-foo/register.js') {
498-
return {};
499-
}
500-
if (path === '@storybook/addon-cool') {
501-
return {};
502-
}
503-
if (path === 'addon-bar') {
504-
return {
505-
addons: ['@storybook/addon-cool'],
506-
presets: [],
507-
};
508-
}
509-
if (path === 'addon-baz/register.js') {
510-
return {};
511-
}
512-
if (path === '@storybook/addon-notes/register-panel') {
513-
return {};
484+
switch (path) {
485+
case '@storybook/react':
486+
case '@storybook/preset-typescript':
487+
case '@storybook/addon-docs/preset':
488+
case 'addon-foo/register.js':
489+
case '@storybook/addon-cool':
490+
case 'addon-baz/register.js':
491+
case '@storybook/addon-notes/register-panel':
492+
return {};
493+
case 'addon-bar':
494+
return {
495+
addons: ['@storybook/addon-cool'],
496+
presets: [],
497+
};
514498
}
515499
throw new Error(`Could not resolve ${path}`);
516500
});

code/core/src/common/presets.ts

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ import type {
1212
StorybookConfigRaw,
1313
} from 'storybook/internal/types';
1414

15-
import { parseNodeModulePath, resolvePathSync } from 'mlly';
15+
import { parseNodeModulePath } from 'mlly';
1616
import { join, parse, resolve } from 'pathe';
1717
import { dedent } from 'ts-dedent';
1818

19-
import { importModule } from '../shared/utils/module';
19+
import { importModule, safeResolveModule } from '../shared/utils/module';
2020
import { getInterpretedFile } from './utils/interpret-files';
2121
import { validateConfigurationFiles } from './utils/validate-configuration-files';
2222

@@ -69,18 +69,7 @@ export const resolveAddonName = (
6969
name: string,
7070
options: any
7171
): CoreCommon_ResolvedAddonPreset | CoreCommon_ResolvedAddonVirtual | undefined => {
72-
const safeResolve = (path: string) => {
73-
try {
74-
return resolvePathSync(path, {
75-
url: configDir,
76-
extensions: ['.mjs', '.js', '.cjs'],
77-
});
78-
} catch (e) {
79-
return undefined;
80-
}
81-
};
82-
83-
const resolved = safeResolve(name);
72+
const resolved = safeResolveModule({ specifier: name, parent: configDir });
8473

8574
if (resolved && parse(name).name === 'preset') {
8675
return {
@@ -89,9 +78,9 @@ export const resolveAddonName = (
8978
};
9079
}
9180

92-
const presetFile = safeResolve(join(name, 'preset'));
93-
const managerFile = safeResolve(join(name, 'manager'));
94-
const previewFile = safeResolve(join(name, 'preview'));
81+
const presetFile = safeResolveModule({ specifier: join(name, 'preset'), parent: configDir });
82+
const managerFile = safeResolveModule({ specifier: join(name, 'manager'), parent: configDir });
83+
const previewFile = safeResolveModule({ specifier: join(name, 'preview'), parent: configDir });
9584

9685
if (managerFile || previewFile || presetFile) {
9786
const previewAnnotations = [];

code/core/src/core-server/build-dev.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export async function buildDevStandalone(
155155

156156
const builderName = typeof builder === 'string' ? builder : builder.name;
157157
const [previewBuilder, managerBuilder] = await Promise.all([
158-
getPreviewBuilder(builderName, options.configDir),
158+
getPreviewBuilder(builderName),
159159
getManagerBuilder(),
160160
]);
161161

code/core/src/core-server/dev-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export async function storybookDevServer(options: Options) {
6060
const builderName = typeof core?.builder === 'string' ? core.builder : core?.builder?.name;
6161

6262
const [previewBuilder, managerBuilder] = await Promise.all([
63-
getPreviewBuilder(builderName, options.configDir),
63+
getPreviewBuilder(builderName),
6464
getManagerBuilder(),
6565
useStatics(app, options),
6666
]);
Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { MissingBuilderError } from 'storybook/internal/server-errors';
22
import type { Builder, Options } from 'storybook/internal/types';
33

4-
import { parseNodeModulePath } from 'mlly';
5-
import { isAbsolute, join } from 'pathe';
4+
import { join } from 'pathe';
65

76
import { importModule, resolvePackageDir } from '../../shared/utils/module';
87

@@ -11,28 +10,17 @@ export async function getManagerBuilder(): Promise<Builder<unknown>> {
1110
return import(builderManagerPath);
1211
}
1312

14-
export async function getPreviewBuilder(
15-
builderName: string,
16-
configDir: string
17-
): Promise<Builder<unknown>> {
18-
let builderPackage;
19-
if (isAbsolute(builderName)) {
20-
// TODO: test this in Yarn PnP
21-
const parsedBuilderPackage = parseNodeModulePath(builderName);
22-
builderPackage = parsedBuilderPackage.name || resolvePackageDir(builderName, configDir);
23-
} else {
24-
builderPackage = import.meta.resolve(builderName, configDir);
25-
}
26-
return await importModule(builderPackage);
13+
export async function getPreviewBuilder(builderName: string): Promise<Builder<unknown>> {
14+
return await importModule(builderName);
2715
}
2816

29-
export async function getBuilders({ presets, configDir }: Options): Promise<Builder<unknown>[]> {
17+
export async function getBuilders({ presets }: Options): Promise<Builder<unknown>[]> {
3018
const { builder } = await presets.apply('core', {});
3119
if (!builder) {
3220
throw new MissingBuilderError();
3321
}
3422

3523
const builderName = typeof builder === 'string' ? builder : builder.name;
3624

37-
return Promise.all([getPreviewBuilder(builderName, configDir), getManagerBuilder()]);
25+
return Promise.all([getPreviewBuilder(builderName), getManagerBuilder()]);
3826
}

code/core/src/shared/utils/module.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { statSync } from 'node:fs';
12
import { createRequire, register } from 'node:module';
23
import { win32 } from 'node:path/win32';
34
import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -67,3 +68,47 @@ export async function importModule(path: string) {
6768
}
6869
return mod.default ?? mod;
6970
}
71+
72+
/**
73+
* Safely resolves a module specifier to its absolute file path.
74+
*
75+
* Attempts to resolve the given module specifier by trying different file extensions until a valid
76+
* file is found. Returns undefined if the module cannot be resolved.
77+
*
78+
* Optionally pass in a list of file extensions to try, defaulting to `.mjs`, `.js`, and `.cjs`.
79+
*
80+
* @example
81+
*
82+
* ```typescript
83+
* // Resolve a relative module
84+
* const path = safeResolveModule({
85+
* specifier: './utils',
86+
* parent: import.meta.url,
87+
* });
88+
*
89+
* // Resolve with custom extensions
90+
* const path = safeResolveModule({
91+
* specifier: './config',
92+
* extensions: ['.json', '.js'],
93+
* });
94+
* ```
95+
*/
96+
export const safeResolveModule = ({
97+
specifier,
98+
parent,
99+
extensions = ['.mjs', '.js', '.cjs'],
100+
}: {
101+
specifier: string;
102+
parent?: string;
103+
extensions?: string[];
104+
}) => {
105+
for (const extension of [''].concat(extensions)) {
106+
try {
107+
const resolvedPath = fileURLToPath(importMetaResolve(specifier + extension, parent));
108+
if (statSync(resolvedPath).isFile()) {
109+
return resolvedPath;
110+
}
111+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
112+
} catch (e) {}
113+
}
114+
};

0 commit comments

Comments
 (0)