diff --git a/code/addons/vitest/tsconfig.json b/code/addons/vitest/tsconfig.json index 8f0586c10653..d2318b7bb29f 100644 --- a/code/addons/vitest/tsconfig.json +++ b/code/addons/vitest/tsconfig.json @@ -5,5 +5,5 @@ "types": ["vitest"], "strict": true }, - "include": ["src/**/*", "./typings.d.ts"], + "include": ["src/**/*", "./typings.d.ts"] } diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index ef1bc814288f..8107b53b9c57 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -167,7 +167,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption ); if (features?.experimentalComponentsManifest) { - const componentManifestGenerator: ComponentManifestGenerator = await presets.apply( + const componentManifestGenerator = await presets.apply( 'experimental_componentManifestGenerator' ); const indexGenerator = await initializedStoryIndexGenerator; diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index b5ec0b04dc90..594f1e886065 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -144,7 +144,7 @@ export async function storybookDevServer(options: Options) { if (features?.experimentalComponentsManifest) { app.use('/manifests/components.json', async (req, res) => { try { - const componentManifestGenerator: ComponentManifestGenerator = await options.presets.apply( + const componentManifestGenerator = await options.presets.apply( 'experimental_componentManifestGenerator' ); const indexGenerator = await initializedStoryIndexGenerator; @@ -169,7 +169,7 @@ export async function storybookDevServer(options: Options) { app.get('/manifests/components.html', async (req, res) => { try { - const componentManifestGenerator: ComponentManifestGenerator = await options.presets.apply( + const componentManifestGenerator = await options.presets.apply( 'experimental_componentManifestGenerator' ); const indexGenerator = await initializedStoryIndexGenerator; @@ -191,7 +191,8 @@ export async function storybookDevServer(options: Options) { // logger?.error?.(e instanceof Error ? e : String(e)); res.statusCode = 500; res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(`
${e instanceof Error ? e.toString() : String(e)}
`); + invariant(e instanceof Error); + res.end(`
${e.toString()}\n${e.stack}
`); } }); } diff --git a/code/core/src/core-server/manifest.ts b/code/core/src/core-server/manifest.ts index 591d1f6aa382..6c3f136b2dea 100644 --- a/code/core/src/core-server/manifest.ts +++ b/code/core/src/core-server/manifest.ts @@ -40,7 +40,7 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) { entries.map(([, it]) => it).filter((it) => it.error), (manifest) => manifest.error?.name ?? 'Error' ) - ); + ).sort(([, a], [, b]) => b.length - a.length); const errorGroupsHTML = errorGroups .map(([error, grouped]) => { @@ -517,15 +517,23 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) { .card > .tg-err:checked ~ .panels .panel-err { display: grid; } - + .card > .tg-warn:checked ~ .panels .panel-warn { display: grid; } - + .card > .tg-stories:checked ~ .panels .panel-stories { display: grid; } + /* Add vertical spacing around panels only when any panel is visible */ + .card > .tg-err:checked ~ .panels, + .card > .tg-warn:checked ~ .panels, + .card > .tg-stories:checked ~ .panels, + .card > .tg-props:checked ~ .panels { + margin: 10px 0; + } + /* Optional: a subtle 1px ring on the active badge, using :has() if available */ @supports selector(.card:has(.tg-err:checked)) { .card:has(.tg-err:checked) label[for$='-err'], @@ -536,6 +544,25 @@ export function renderManifestComponentsPage(manifest: ComponentsManifest) { border-color: currentColor; } } + + /* Wrap long lines in code blocks at ~120 characters */ + pre, code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + } + pre { + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + overflow-x: auto; /* fallback for extremely long tokens */ + margin: 8px 0 0; + } + pre > code { + display: block; + white-space: inherit; + overflow-wrap: inherit; + word-break: inherit; + inline-size: min(100%, 120ch); + } @@ -747,19 +774,36 @@ function renderComponentCard(key: string, c: ComponentManifest, id: string) { ${esc(ex.name)} story error + ${ex?.summary ? `
Summary: ${esc(ex.summary)}
` : ''} + ${ex?.description ? `
${esc(ex.description)}
` : ''} ${ex?.snippet ? `
${esc(ex.snippet)}
` : ''} ${ex?.error?.message ? `
${esc(ex.error.message)}
` : ''} ` ) .join('')} + + + ${ + c.import + ? `
+
+ Imports +
+
${c.import}
+
` + : '' + } + ${okStories .map( - (ex, k) => ` + (ex) => `
${esc(ex.name)} story ok
+ ${ex?.summary ? `
${esc(ex.summary)}
` : ''} + ${ex?.description ? `
${esc(ex.description)}
` : ''} ${ex?.snippet ? `
${esc(ex.snippet)}
` : ''}
` ) diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index 229e7489608e..2b7d75a0fe9a 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -352,7 +352,13 @@ export interface ComponentManifest { description?: string; import?: string; summary?: string; - stories: { name: string; snippet?: string; error?: { name: string; message: string } }[]; + stories: { + name: string; + snippet?: string; + description?: string; + summary?: string; + error?: { name: string; message: string }; + }[]; jsDocTags: Record; error?: { name: string; message: string }; } diff --git a/code/renderers/react/package.json b/code/renderers/react/package.json index edef2a81a8d0..c352db9790b4 100644 --- a/code/renderers/react/package.json +++ b/code/renderers/react/package.json @@ -67,6 +67,7 @@ "acorn-walk": "^7.2.0", "babel-plugin-react-docgen": "^4.2.1", "comment-parser": "^1.4.1", + "empathic": "^2.0.0", "es-toolkit": "^1.36.0", "escodegen": "^2.1.0", "expect-type": "^0.15.0", diff --git a/code/renderers/react/src/componentManifest/generator.test.ts b/code/renderers/react/src/componentManifest/generator.test.ts index fa13535eb68c..16097f5c6c9a 100644 --- a/code/renderers/react/src/componentManifest/generator.test.ts +++ b/code/renderers/react/src/componentManifest/generator.test.ts @@ -6,9 +6,33 @@ import { vol } from 'memfs'; import { dedent } from 'ts-dedent'; import { componentManifestGenerator } from './generator'; +import { fsMocks } from './test-utils'; +vi.mock('storybook/internal/common', async (importOriginal) => ({ + ...(await importOriginal()), + // Keep it simple: hardcode known inputs to expected outputs for this test. + resolveImport: (id: string) => { + return { + './Button': './src/stories/Button.tsx', + './Header': './src/stories/Header.tsx', + }[id]; + }, + JsPackageManagerFactory: { + getPackageManager: () => ({ + primaryPackageJson: { + packageJson: { + name: 'some-package', + }, + }, + }), + }, +})); vi.mock('node:fs/promises', async () => (await import('memfs')).fs.promises); vi.mock('node:fs', async () => (await import('memfs')).fs); + +vi.mock('empathic/find', async () => ({ + up: (path: string) => '/app/package.json', +})); vi.mock('tsconfig-paths', () => ({ loadConfig: () => ({ resultType: null!, message: null! }) })); // Use the provided indexJson from this file @@ -95,118 +119,13 @@ const indexJson = { beforeEach(() => { vi.spyOn(process, 'cwd').mockReturnValue('/app'); - vol.fromJSON( - { - ['./src/stories/Button.stories.ts']: dedent` - import type { Meta, StoryObj } from '@storybook/react'; - import { fn } from 'storybook/test'; - import { Button } from './Button'; - - const meta = { - component: Button, - args: { onClick: fn() }, - } satisfies Meta; - export default meta; - type Story = StoryObj; - - export const Primary: Story = { args: { primary: true, label: 'Button' } }; - export const Secondary: Story = { args: { label: 'Button' } }; - export const Large: Story = { args: { size: 'large', label: 'Button' } }; - export const Small: Story = { args: { size: 'small', label: 'Button' } };`, - ['./src/stories/Button.tsx']: dedent` - import React from 'react'; - export interface ButtonProps { - /** Description of primary */ - primary?: boolean; - backgroundColor?: string; - size?: 'small' | 'medium' | 'large'; - label: string; - onClick?: () => void; - } - - /** Primary UI component for user interaction */ - export const Button = ({ - primary = false, - size = 'medium', - backgroundColor, - label, - ...props - }: ButtonProps) => { - const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary'; - return ( - - ); - };`, - ['./src/stories/Header.stories.ts']: dedent` - import type { Meta, StoryObj } from '@storybook/react'; - import { fn } from 'storybook/test'; - import Header from './Header'; - - /** - * Description from meta and very long. - * @summary Component summary - * @import import { Header } from '@design-system/components/Header'; - */ - const meta = { - component: Header, - args: { - onLogin: fn(), - onLogout: fn(), - onCreateAccount: fn(), - } - } satisfies Meta; - export default meta; - type Story = StoryObj; - export const LoggedIn: Story = { args: { user: { name: 'Jane Doe' } } }; - export const LoggedOut: Story = {}; - `, - ['./src/stories/Header.tsx']: dedent` - import { Button } from './Button'; - - export interface HeaderProps { - user?: User; - onLogin?: () => void; - onLogout?: () => void; - onCreateAccount?: () => void; - } - - export default ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => ( -
-
-
- {user ? ( - <> - - Welcome, {user.name}! - -
-
-
- );`, - }, - '/app' - ); + vol.fromJSON(fsMocks, '/app'); return () => vol.reset(); }); test('componentManifestGenerator generates correct id, name, description and examples ', async () => { - const generator = await componentManifestGenerator(); - const manifest = await generator({ + const generator = await componentManifestGenerator(undefined, { configDir: '.storybook' } as any); + const manifest = await generator?.({ getIndex: async () => indexJson, } as unknown as StoryIndexGenerator); @@ -217,14 +136,19 @@ test('componentManifestGenerator generates correct id, name, description and exa "description": "Primary UI component for user interaction", "error": undefined, "id": "example-button", - "import": undefined, - "jsDocTags": {}, + "import": "import { Button } from \"@design-system/components/Button\";", + "jsDocTags": { + "import": [ + "import { Button } from '@design-system/components/Button';", + ], + }, "name": "Button", "path": "./src/stories/Button.stories.ts", "reactDocgen": { "actualName": "Button", - "definedInFile": "/app/src/stories/Button.tsx", - "description": "Primary UI component for user interaction", + "definedInFile": "./src/stories/Button.tsx", + "description": "Primary UI component for user interaction + @import import { Button } from '@design-system/components/Button';", "displayName": "Button", "exportName": "Button", "methods": [], @@ -299,20 +223,28 @@ test('componentManifestGenerator generates correct id, name, description and exa }, "stories": [ { + "description": undefined, "name": "Primary", "snippet": "const Primary = () => ;", + "summary": undefined, }, { + "description": undefined, "name": "Secondary", "snippet": "const Secondary = () => ;", + "summary": undefined, }, { + "description": undefined, "name": "Large", "snippet": "const Large = () => ;", + "summary": undefined, }, { + "description": undefined, "name": "Small", "snippet": "const Small = () => ;", + "summary": undefined, }, ], "summary": undefined, @@ -321,7 +253,7 @@ test('componentManifestGenerator generates correct id, name, description and exa "description": "Description from meta and very long.", "error": undefined, "id": "example-header", - "import": "import { Header } from '@design-system/components/Header';", + "import": "import { Header } from \"@design-system/components/Header\";", "jsDocTags": { "import": [ "import { Header } from '@design-system/components/Header';", @@ -334,8 +266,8 @@ test('componentManifestGenerator generates correct id, name, description and exa "path": "./src/stories/Header.stories.ts", "reactDocgen": { "actualName": "", - "definedInFile": "/app/src/stories/Header.tsx", - "description": "", + "definedInFile": "./src/stories/Header.tsx", + "description": "@import import { Header } from '@design-system/components/Header';", "exportName": "default", "methods": [], "props": { @@ -395,16 +327,20 @@ test('componentManifestGenerator generates correct id, name, description and exa }, "stories": [ { + "description": undefined, "name": "LoggedIn", "snippet": "const LoggedIn = () =>
;", + "summary": undefined, }, { + "description": undefined, "name": "LoggedOut", "snippet": "const LoggedOut = () =>
;", + "summary": undefined, }, ], "summary": "Component summary", @@ -418,6 +354,7 @@ test('componentManifestGenerator generates correct id, name, description and exa async function getManifestForStory(code: string) { vol.fromJSON( { + ['./package.json']: JSON.stringify({ name: 'some-package' }), ['./src/stories/Button.stories.ts']: code, ['./src/stories/Button.tsx']: dedent` import React from 'react'; @@ -441,7 +378,7 @@ async function getManifestForStory(code: string) { '/app' ); - const generator = await componentManifestGenerator(); + const generator = await componentManifestGenerator(undefined, { configDir: '.storybook' } as any); const indexJson = { v: 5, entries: { @@ -459,11 +396,11 @@ async function getManifestForStory(code: string) { }, }; - const manifest = await generator({ + const manifest = await generator?.({ getIndex: async () => indexJson, } as unknown as StoryIndexGenerator); - return manifest.components['example-button']; + return manifest?.components?.['example-button']; } function withCSF3(body: string) { @@ -497,13 +434,13 @@ test('fall back to index title when no component name', async () => { "description": "Primary UI component for user interaction", "error": undefined, "id": "example-button", - "import": undefined, + "import": "import { Button } from \"some-package\";", "jsDocTags": {}, "name": "Button", "path": "./src/stories/Button.stories.ts", "reactDocgen": { "actualName": "Button", - "definedInFile": "/app/src/stories/Button.tsx", + "definedInFile": "./src/stories/Button.tsx", "description": "Primary UI component for user interaction", "displayName": "Button", "exportName": "Button", @@ -524,8 +461,10 @@ test('fall back to index title when no component name', async () => { }, "stories": [ { + "description": undefined, "name": "Primary", "snippet": "const Primary = () => ; + `; + expect(await getImports(code)).toMatchInlineSnapshot( + ` + { + "components": [ + { + "componentName": "Button", + "importId": "@design-system/button", + "importName": "Button", + "localImportName": "Button", + "path": undefined, + }, + { + "componentName": "ButtonGroup", + "importId": "@design-system/button-group", + "importName": "ButtonGroup", + "localImportName": "ButtonGroup", + "path": undefined, + }, + ], + "imports": [ + "import { Button } from "@design-system/button";", + "import { ButtonGroup } from "@design-system/button-group";", + ], + } + ` + ); +}); + +test('Namespace import with member usage', async () => { + const code = dedent` + import * as Accordion from '@ds/accordion'; + + const meta = {}; + export default meta; + export const S = Hi; + `; + expect(await getImports(code)).toMatchInlineSnapshot( + ` + { + "components": [ + { + "componentName": "Accordion.Root", + "importId": "@ds/accordion", + "importName": "Root", + "localImportName": "Accordion", + "namespace": "Accordion", + "path": undefined, + }, + ], + "imports": [ + "import * as Accordion from "@ds/accordion";", + ], + } + ` + ); +}); + +test('Named import used as namespace object', async () => { + const code = dedent` + import { Accordion } from '@ds/accordion'; + + const meta = {}; + export default meta; + export const S = Hi; + `; + expect(await getImports(code)).toMatchInlineSnapshot( + ` + { + "components": [ + { + "componentName": "Accordion.Root", + "importId": "@ds/accordion", + "importName": "Accordion", + "localImportName": "Accordion", + "path": undefined, + }, + ], + "imports": [ + "import { Accordion } from "@ds/accordion";", + ], + } + ` + ); +}); + +test('Default import', async () => { + const code = dedent` + import Button from '@ds/button'; + + const meta = {}; + export default meta; + export const S = + ); + };`, + ['./src/stories/Header.stories.ts']: dedent` + import type { Meta, StoryObj } from '@storybook/react'; + import { fn } from 'storybook/test'; + import Header from './Header'; + + /** + * Description from meta and very long. + * @summary Component summary + * @import import { Header } from '@design-system/components/Header'; + */ + const meta = { + component: Header, + args: { + onLogin: fn(), + onLogout: fn(), + onCreateAccount: fn(), + } + } satisfies Meta; + export default meta; + type Story = StoryObj; + export const LoggedIn: Story = { args: { user: { name: 'Jane Doe' } } }; + export const LoggedOut: Story = {}; + `, + ['./src/stories/Header.tsx']: dedent` + import { Button } from './Button'; + + export interface HeaderProps { + user?: User; + onLogin?: () => void; + onLogout?: () => void; + onCreateAccount?: () => void; + } + + /** + * @import import { Header } from '@design-system/components/Header'; + */ + export default ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => ( +
+
+
+ {user ? ( + <> + + Welcome, {user.name}! + +
+
+
+ );`, +}; diff --git a/code/renderers/react/src/componentManifest/utils.test.ts b/code/renderers/react/src/componentManifest/utils.test.ts new file mode 100644 index 000000000000..7b89145e8276 --- /dev/null +++ b/code/renderers/react/src/componentManifest/utils.test.ts @@ -0,0 +1,131 @@ +import { expect, test, vi } from 'vitest'; + +import { cached, groupBy, invalidateCache, invariant } from './utils'; + +// Helpers +const calls = () => { + let n = 0; + return { + inc: () => ++n, + count: () => n, + }; +}; + +test('groupBy groups items by key function', () => { + const items = [ + { k: 'a', v: 1 }, + { k: 'b', v: 2 }, + { k: 'a', v: 3 }, + ]; + const grouped = groupBy(items, (it) => it.k); + expect(grouped).toMatchInlineSnapshot(` + { + "a": [ + { + "k": "a", + "v": 1, + }, + { + "k": "a", + "v": 3, + }, + ], + "b": [ + { + "k": "b", + "v": 2, + }, + ], + } + `); +}); + +test('invariant throws only when condition is falsy and lazily evaluates message', () => { + const spy = vi.fn(() => 'Expensive message'); + + // True branch: does not throw and does not call message factory + expect(() => invariant(true, spy)).not.toThrow(); + expect(spy).not.toHaveBeenCalled(); + + // False branch: throws and evaluates message lazily + expect(() => invariant(false, spy)).toThrowError('Expensive message'); + expect(spy).toHaveBeenCalledTimes(1); +}); + +test('cached memoizes by default on first argument value', () => { + const c = calls(); + const fn = (x: number) => (c.inc(), x * 2); + const m = cached(fn); + + expect(m(2)).toBe(4); + expect(m(2)).toBe(4); + expect(m(3)).toBe(6); + expect(m(3)).toBe(6); + + // Underlying function should have been called only once per distinct key (2 keys => 2 calls) + expect(c.count()).toBe(2); +}); + +test('cached supports custom key selector', () => { + const c = calls(); + const fn = (x: number, y: number) => (c.inc(), x + y); + // Cache only by the first arg + const m = cached(fn, { key: (x) => `${x}` }); + + expect(m(1, 10)).toBe(11); + expect(m(1, 99)).toBe(11); // cached by key 1, result should be from first call + expect(m(2, 5)).toBe(7); + expect(m(2, 8)).toBe(7); + + expect(c.count()).toBe(2); +}); + +test('cached stores and returns undefined results without recomputing', () => { + const c = calls(); + const fn = (x: string) => { + c.inc(); + return x === 'hit' ? undefined : x.toUpperCase(); + }; + const m = cached(fn); + + expect(m('hit')).toBeUndefined(); + expect(m('hit')).toBeUndefined(); + expect(m('miss')).toBe('MISS'); + expect(m('miss')).toBe('MISS'); + + expect(c.count()).toBe(2); +}); + +test('cached shares cache across wrappers of the same function', () => { + const c = calls(); + const f = (x: string) => (c.inc(), x.length); + + const m1 = cached(f, { key: (x) => x }); + const m2 = cached(f, { key: (x) => x }); + + // First computes via m1 and caches the value 3 for key 'foo' + expect(m1('foo')).toBe(3); + // m2 should now return the cached value (from shared module store), not call f again + expect(m2('foo')).toBe(3); + + // Verify call counts: underlying function called once + expect(c.count()).toBe(1); +}); + +test('invalidateCache clears the module-level memo store', () => { + const c = calls(); + const f = (x: number) => (c.inc(), x * 2); + const m = cached(f); + + expect(m(2)).toBe(4); + expect(c.count()).toBe(1); + + // Cached result + expect(m(2)).toBe(4); + expect(c.count()).toBe(1); + + // Invalidate and ensure it recomputes + invalidateCache(); + expect(m(2)).toBe(4); + expect(c.count()).toBe(2); +}); diff --git a/code/renderers/react/src/componentManifest/utils.ts b/code/renderers/react/src/componentManifest/utils.ts index bd61797f98ad..9b03823211c5 100644 --- a/code/renderers/react/src/componentManifest/utils.ts +++ b/code/renderers/react/src/componentManifest/utils.ts @@ -1,11 +1,20 @@ // Object.groupBy polyfill +import { readFileSync } from 'node:fs'; + +import { resolveImport } from 'storybook/internal/common'; +import { logger } from 'storybook/internal/node-logger'; + +import * as find from 'empathic/find'; + export const groupBy = ( items: T[], keySelector: (item: T, index: number) => K ) => { return items.reduce>>((acc = {}, item, index) => { const key = keySelector(item, index); - acc[key] ??= []; + if (!Array.isArray(acc[key])) { + acc[key] = []; + } acc[key].push(item); return acc; }, {}); @@ -21,3 +30,99 @@ export function invariant( } throw new Error((typeof message === 'function' ? message() : message) ?? 'Invariant failed'); } + +// Module-level cache stores: per-function caches keyed by derived string keys +// memoStore caches synchronous function results +let memoStore: WeakMap<(...args: any[]) => any, Map> = new WeakMap(); +// asyncMemoStore caches resolved values for async functions (never stores Promises) +let asyncMemoStore: WeakMap<(...args: any[]) => any, Map> = new WeakMap(); + +// Generic cache/memoization helper +// - Caches by a derived key from the function arguments (must be a string) +// - Supports caching of `undefined` results (uses Map.has to distinguish) +// - Uses module-level stores so multiple wrappers around the same function share cache +// - Never stores a Promise; for async functions, we cache only the resolved value. Concurrent calls are not de-duped. +export const cached = ( + fn: (...args: A) => R, + opts: { key?: (...args: A) => string; name?: string } = {} +): ((...args: A) => R) => { + const keyOf: (...args: A) => string = + opts.key ?? + ((...args: A) => { + try { + // Prefer a stable string key based on the full arguments list + return JSON.stringify(args) ?? String(args[0]); + } catch { + // Fallback: use the first argument if it is not serializable + return String(args[0]); + } + }); + + return (...args: A) => { + const k = keyOf(...args); + const name = fn.name || opts.name || 'anonymous'; + + // Ensure stores exist for this function + let syncStore = memoStore.get(fn); + if (!syncStore) { + syncStore = new Map(); + memoStore.set(fn, syncStore); + } + let asyncStore = asyncMemoStore.get(fn); + if (!asyncStore) { + asyncStore = new Map(); + asyncMemoStore.set(fn, asyncStore); + } + + // Fast path: sync cached + if (syncStore.has(k)) { + // Log cache hit + try { + logger.verbose(`[cache] hit (sync) ${name} key=${k}`); + } catch {} + return syncStore.get(k); + } + + // Fast path: async resolved cached + if (asyncStore.has(k)) { + logger.verbose(`[cache] hit (async) ${name} key=${k}`); + return Promise.resolve(asyncStore.get(k)); + } + + // Compute result with benchmarking + const start = Date.now(); + const result = fn(...args); + + // If it's a promise-returning function, cache the resolved value later + const isPromise = + result && + typeof result === 'object' && + 'then' in result && + typeof (result as any).then === 'function'; + if (isPromise) { + return (result as any).then((val: any) => { + const duration = Date.now() - start; + asyncStore!.set(k, val); + logger.verbose(`[cache] miss ${name} took ${duration}ms key=${k}`); + return val as R; + }); + } else { + const duration = Date.now() - start; + syncStore.set(k, result); + logger.verbose(`[cache] miss ${name} took ${duration}ms key=${k}`); + return result; + } + }; +}; + +export const invalidateCache = () => { + // Reinitialize the module-level stores + memoStore = new WeakMap(); + asyncMemoStore = new WeakMap(); +}; + +export const cachedReadFileSync = cached(readFileSync, { name: 'cachedReadFile' }); + +export const cachedFindUp = cached(find.up, { name: 'findUp' }); + +export const cachedResolveImport = cached(resolveImport, { name: 'resolveImport' }); diff --git a/code/renderers/react/vitest.setup.ts b/code/renderers/react/vitest.setup.ts index 3245d9759235..9d94dc3342d7 100644 --- a/code/renderers/react/vitest.setup.ts +++ b/code/renderers/react/vitest.setup.ts @@ -1,6 +1,10 @@ import { afterEach, vi } from 'vitest'; +import { invalidateCache } from './src/componentManifest/utils'; + afterEach(() => { // can not run in beforeEach because then all { spy: true } mocks get removed vi.restoreAllMocks(); + + invalidateCache(); }); diff --git a/code/vitest-setup.ts b/code/vitest-setup.ts index a13755321eb5..c0b325e01289 100644 --- a/code/vitest-setup.ts +++ b/code/vitest-setup.ts @@ -90,6 +90,7 @@ vi.mock('storybook/internal/node-logger', async (importOriginal) => { info: vi.fn(), trace: vi.fn(), debug: vi.fn(), + verbose: vi.fn(), logBox: vi.fn(), intro: vi.fn(), outro: vi.fn(), diff --git a/code/yarn.lock b/code/yarn.lock index d6028e53f751..d324b60ab7ad 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -6824,6 +6824,7 @@ __metadata: acorn-walk: "npm:^7.2.0" babel-plugin-react-docgen: "npm:^4.2.1" comment-parser: "npm:^1.4.1" + empathic: "npm:^2.0.0" es-toolkit: "npm:^1.36.0" escodegen: "npm:^2.1.0" expect-type: "npm:^0.15.0"