Optimizing Type Extraction and Performance for Styleguidist

July 09, 2024

7 min read999 views

Not long ago I had the task of improving how we were generating the component props table for our UI library documentation on Styleguidist.

One of the issues we were having was that the library we used for extracting types wasn’t handling nested props well. We were getting only the object type name, but not its internal type props.

Styleguidist’s official documentation recommends using react-docgen, one of the most popular libraries for extracting type information. Our Styleguidist configuration file was as follows:

styleguidist.config.js
const fs = require("fs-extra");
const path = require("path");

const options = {
  shouldExtractLiteralValuesFromEnum: true,
  shouldRemoveUndefinedFromOptional: true,
  propFilter: (prop) => {
    if (prop.declarations !== undefined && prop.declarations.length > 0) {
      const hasPropAdditionalDescription = prop.declarations.find((declaration) => {
        return !declaration.fileName.includes("index.d.ts"); // We want to avoid the primitive type properties
      });
      return Boolean(hasPropAdditionalDescription);
    }
    return true;
  },
};

const jsParse = require("react-docgen");
const tsParser = require("react-docgen-typescript").withCustomConfig("./tsconfig.json", options);

module.exports = {
  propsParser: (filePath, code, resolver, handlers) => {
    if (/tsx?$/.test(filePath)) return tsParser.parse(filePath);
    return jsParse.parse(code, resolver, handlers, { filename: filePath });
  },
...

During my research, I found that react-docgen limits the depth when searching for types to improve performance while parsing files. I started looking for some alternatives that could handle deep recursive parsing more effectively. This led me to structured-types, a powerful library, but it came with a significant downside — it was much slower (almost three times as slow).

To balance better type documentation with performance, I decided to use the new parsing library on demand—only when specific shape objects needed to be documented. Here’s what I implemented:

styleguidist.config.js
const fs = require("fs-extra");
const path = require("path");

const options = {
  shouldExtractLiteralValuesFromEnum: true,
  shouldRemoveUndefinedFromOptional: true,
  propFilter: (prop) => {
    if (prop.declarations !== undefined && prop.declarations.length > 0) {
      const hasPropAdditionalDescription = prop.declarations.find((declaration) => {
        return !declaration.fileName.includes("index.d.ts"); // We want to avoid the primitive type properties
      });
      return Boolean(hasPropAdditionalDescription);
    }
    return true;
  },
};

const jsParse = require("react-docgen");
const tsParser = require("react-docgen-typescript").withCustomConfig("./tsconfig.json", options);
const parser = require("@structured-types/api");

const bubbleSearch = (props, name) => {
	let result = null;
	props.map((prop) => {
		if (prop.type === name) {
			result = prop;
		}
		if (prop.properties) {
			return bubbleSearch(prop.properties, name);
		}
	});
	return result;
};

module.exports = {
  updateDocs(docs, file) {
	/**
	 * Logic to add the expand prop to the docs object
	 * This is used to display nested props in the props table
	 */
	const hasExpand = docs.props.some(({ tags }) => tags.expand);
	if (hasExpand) {
		const advanceParser = parser.parseFiles([file]);
		const props = docs.props.map((prop) => {
			if (prop?.tags?.expand?.length > 0) {
				const expandTag = prop.tags.expand[0];
				let expand = null;

				for (const key of Object.keys(advanceParser)) {
					const value = bubbleSearch(advanceParser[key].properties, expandTag.description);
					if (value) {
						expand = value;
						break;
					}
				}
				return {
					...prop,
					expand: expand,
				};
			}
			return prop;
		});
		return {
			...docs,
			props,
		};
	}
	return docs;
  },
  propsParser: (filePath, code, resolver, handlers) => {
    if (/tsx?$/.test(filePath)) return tsParser.parse(filePath);
    return jsParse.parse(code, resolver, handlers, { filename: filePath });
  },
...

The main idea was to check if a component had the @expand [type name] tag, and if so, use the new library to parse that type. For example, if I have a component Autocomplete with this interface:

export interface AutocompleteProps {
	/**
	 * The html id
	 */
	id?: string;
	/**
	 * The options to select from the autocomplete field.
	 *  @expand OptionShape
	 */
	options?: OptionShape[];
...

Styleguidist will detect the @expand tag and parse the OptionShape type to generate its detailed description.

While this approach worked well, there was still room for improvement. I implemented a simple yet effective caching system to prevent re-parsing unchanged files. Additionally, while exploring type extraction, I discovered that many type extraction libraries use a service host under the hood for extracting type information from TypeScript files. By creating a service host with additional configuration to avoid re-parsing the same type definitions multiple times, we were able to reduce the processing time substantially.

The final styleguidist.config.js file looked like this:

styleguidist.config.js
const fs = require("fs-extra");
const path = require("path");
const ts = require("typescript");
const { createHash } = require("crypto");

function cacheProps(filePath) {
	let docgenInfo = null;
	const cacheFolder = path.join(__dirname, "react-docgen-cache");
	//create cache folder if it doesn't exist
	if (!fs.existsSync(cacheFolder)) {
		fs.mkdirSync(cacheFolder, { recursive: true });
	}
	const cachedFileName = path.join(cacheFolder, createHash("md5").update(filePath).digest("hex"));
	if (fs.existsSync(cachedFileName)) {
		const cacheStats = fs.statSync(cachedFileName);
		const fileStats = fs.statSync(filePath);
		if (cacheStats.mtime.getTime() >= fileStats.mtime.getTime()) {
			const fileData = fs.readFileSync(cachedFileName);
			docgenInfo = JSON.parse(fileData);
		}
	}
	return {
		docgenInfo,
		cachedFileName,
	};
}

function readFile(fileName) {
	fileName = path.normalize(fileName);
	try {
		return fs.readFileSync(fileName).toString();
	} catch (e) {
		return undefined;
	}
}

function createServiceHost(compilerOptions, files) {
	return {
		getScriptFileNames: () => {
			return [...Array.from(files.keys())].filter((filePath) => filePath.match(/\.tsx?$/i));
		},
		getScriptVersion: (fileName) => {
			fileName = path.normalize(fileName);
			const file = files.get(fileName);
			return file === undefined ? "" : file.version.toString();
		},
		getScriptSnapshot: (fileName) => {
			// This is called any time TypeScript needs a file's text
			// We either load from memory or from disk
			fileName = path.normalize(fileName);
			let file = files.get(fileName);

			if (file === undefined) {
				const text = readFile(fileName);
				if (text === undefined) {
					return undefined;
				}

				file = { version: 0, text };
				files.set(fileName, file);
			}

			return ts.ScriptSnapshot.fromString(file.text);
		},
		getCurrentDirectory: () => process.cwd(),
		getCompilationSettings: () => compilerOptions,
		getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
		fileExists: ts.sys.fileExists,
		readFile: ts.sys.readFile,
		readDirectory: ts.sys.readDirectory,
	};
}

function getTSConfigFile(tsconfigPath) {
	const basePath = path.dirname(tsconfigPath);
	const config = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
	if (config.error) {
		throw new Error(ts.formatDiagnostic(config.error, formatHost));
	}
	return ts.parseJsonConfigFileContent(config.config, ts.sys, basePath, undefined, tsconfigPath);
}

const options = {
  shouldExtractLiteralValuesFromEnum: true,
  shouldRemoveUndefinedFromOptional: true,
  propFilter: (prop) => {
    if (prop.declarations !== undefined && prop.declarations.length > 0) {
      const hasPropAdditionalDescription = prop.declarations.find((declaration) => {
        return !declaration.fileName.includes("index.d.ts"); // We want to avoid the primitive type properties
      });
      return Boolean(hasPropAdditionalDescription);
    }
    return true;
  },
};

let languageService = null;
const filesCache = new Map();

const jsParse = require("react-docgen");
const tsParser = require("react-docgen-typescript").withCustomConfig("./tsconfig.json", options);
const structuredTypesParser = require("@structured-types/api");

const bubbleSearch = (props, name) => {
	let result = null;
	props.map((prop) => {
		if (prop.type === name) {
			result = prop;
		}
		if (prop.properties) {
			return bubbleSearch(prop.properties, name);
		}
	});
	return result;
};

module.exports = {
  updateDocs(docs, file) {
	/**
	 * Logic to add the expand prop to the docs object
	 * This is used to display nested props in the props table
	 */
	const hasExpand = docs.props.some(({ tags }) => tags.expand);
	if (hasExpand) {
		const advanceParser = structuredTypesParser.parseFiles([file]);
		const props = docs.props.map((prop) => {
			if (prop?.tags?.expand?.length > 0) {
				const expandTag = prop.tags.expand[0];
				let expand = null;

				for (const key of Object.keys(advanceParser)) {
					const value = bubbleSearch(advanceParser[key].properties, expandTag.description);
					if (value) {
						expand = value;
						break;
					}
				}
				return {
					...prop,
					expand: expand,
				};
			}
			return prop;
		});
		return {
			...docs,
			props,
		};
	}
	return docs;
  },
  propsParser: (filePath, code, resolver, handlers) => {
    	// Check if the file is cached, if so return the cached version
		// If the file was modified since the cache was created, the cache will be invalidated
		const { docgenInfo, cachedFileName } = cacheProps(filePath);
		if (docgenInfo) {
			return docgenInfo;
		} else {
			filesCache.set(filePath, {
				text: code,
				version: 0,
			});
			let parsed = null;
			if (filePath.match(/\.tsx?$/i)) {
				const programProvider = () => {
					if (languageService === null) {
						languageService = ts.createLanguageService(createServiceHost(tsConfigFile.options, filesCache));
					}
					return languageService.getProgram();
				};
				parsed = tsParser.parseWithProgramProvider(filePath, programProvider);
			} else {
				parsed = jsParse.parse(code, resolver, handlers, { filename: filePath });
			}
			fs.writeFileSync(cachedFileName, JSON.stringify(parsed));
			return parsed;
		}
  },
...

In conclusion, these improvements reduced the initial build time of a project with over 120 components from more than 4 minutes to 2.4 minutes. With caching, we further reduced the time to just 1 minute. I hope this information is helpful!