fix(storybook): improve speed of storybook plugin (#31277)

## Current Behavior
#22953 updated the way that storybook parsing works to always do full TS
tree resolution instead of AST parsing. While this is more accurate,
it's orders of magnitude slower...creating a bottleneck in graph
creation for larger repos which use the plugin.

The only reason we need to do this complex functionality is to determine
if we use angular or not.

## Expected Behavior
Graph creation should be quite fast. 

This PR returns the old behavior, and uses the new behavior as an
additive fallback. In most cases this will result in extremely fast
parsing when the framework is defined inline, and in the failure case,
it will result in unnoticeably slower parsing as the incremental
difference is minor.

Before:
```
Time for '@nx/storybook/plugin:createNodes' 13536.203667
```

After:
```
Time for '@nx/storybook/plugin:createNodes' 292.584667
```

An alternative solve (at least in our case) would be to add an option to
skip angular detection...essentially letting people bypass the whole
reason for doing this config parsing. Although that's probably not a
sustainable option.

NOTE: A majority of the remaining slowness in this plugin is spent
hashing the files for the target cache. If we wanted to, we could
further speed this up by making some assumptions there...but that may
drastically harm repos which rely on the fully resolution behavior

## Related Issue(s)
Fixes #31276
This commit is contained in:
Charlie Croom 2025-05-22 08:22:01 -04:00 committed by GitHub
parent 7e0719cc0a
commit 3a33d5f54f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -5,7 +5,6 @@ import {
createNodesFromFiles, createNodesFromFiles,
CreateNodesV2, CreateNodesV2,
detectPackageManager, detectPackageManager,
getPackageManagerCommand,
joinPathFragments, joinPathFragments,
logger, logger,
parseJson, parseJson,
@ -22,6 +21,7 @@ import { getLockFileName } from '@nx/js';
import { loadConfigFile } from '@nx/devkit/src/utils/config-utils'; import { loadConfigFile } from '@nx/devkit/src/utils/config-utils';
import type { StorybookConfig } from '@storybook/types'; import type { StorybookConfig } from '@storybook/types';
import { hashObject } from 'nx/src/hasher/file-hasher'; import { hashObject } from 'nx/src/hasher/file-hasher';
import { tsquery } from '@phenomnomnominal/tsquery';
export interface StorybookPluginOptions { export interface StorybookPluginOptions {
buildStorybookTargetName?: string; buildStorybookTargetName?: string;
@ -163,10 +163,11 @@ async function buildStorybookTargets(
const namedInputs = getNamedInputs(projectRoot, context); const namedInputs = getNamedInputs(projectRoot, context);
const storybookFramework = await getStorybookFramework( // First attempt to do a very fast lookup for the framework
configFilePath, // If that fails, the framework might be inherited, so do a very heavyweight lookup
context const storybookFramework =
); (await getStorybookFramework(configFilePath, context)) ||
(await getStorybookFullyResolvedFramework(configFilePath, context));
const frameworkIsAngular = storybookFramework === '@storybook/angular'; const frameworkIsAngular = storybookFramework === '@storybook/angular';
@ -328,6 +329,63 @@ function serveStaticTarget(
async function getStorybookFramework( async function getStorybookFramework(
configFilePath: string, configFilePath: string,
context: CreateNodesContext context: CreateNodesContext
): Promise<string | undefined> {
const resolvedPath = join(context.workspaceRoot, configFilePath);
const mainTsJs = readFileSync(resolvedPath, 'utf-8');
const importDeclarations = tsquery.query(
mainTsJs,
'ImportDeclaration:has(ImportSpecifier:has([text="StorybookConfig"]))'
)?.[0];
if (!importDeclarations) {
return parseFrameworkName(mainTsJs);
}
const storybookConfigImportPackage = tsquery.query(
importDeclarations,
'StringLiteral'
)?.[0];
if (storybookConfigImportPackage?.getText() === `'@storybook/core-common'`) {
return parseFrameworkName(mainTsJs);
}
return storybookConfigImportPackage?.getText();
}
function parseFrameworkName(mainTsJs: string) {
const frameworkPropertyAssignment = tsquery.query(
mainTsJs,
`PropertyAssignment:has(Identifier:has([text="framework"]))`
)?.[0];
if (!frameworkPropertyAssignment) {
return undefined;
}
const propertyAssignments = tsquery.query(
frameworkPropertyAssignment,
`PropertyAssignment:has(Identifier:has([text="name"]))`
);
const namePropertyAssignment = propertyAssignments?.find((expression) => {
return expression.getText().startsWith('name');
});
if (!namePropertyAssignment) {
const storybookConfigImportPackage = tsquery.query(
frameworkPropertyAssignment,
'StringLiteral'
)?.[0];
return storybookConfigImportPackage?.getText();
}
return tsquery.query(namePropertyAssignment, `StringLiteral`)?.[0]?.getText();
}
async function getStorybookFullyResolvedFramework(
configFilePath: string,
context: CreateNodesContext
): Promise<string> { ): Promise<string> {
const resolvedPath = join(context.workspaceRoot, configFilePath); const resolvedPath = join(context.workspaceRoot, configFilePath);
const { framework } = await loadConfigFile<StorybookConfig>(resolvedPath); const { framework } = await loadConfigFile<StorybookConfig>(resolvedPath);