Skip to content

Commit 5fe3f7b

Browse files
authored
Merge pull request storybookjs#35305 from storybookjs/norbert/oom-docgen-fix
DocgenServer: Fix OOM by recycling the shared TS program
2 parents 9efd8af + 994214b commit 5fe3f7b

8 files changed

Lines changed: 1211 additions & 4 deletions

File tree

code/renderers/react/src/componentManifest/componentMeta/ComponentMetaManager.test.ts

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import * as fs from 'node:fs';
22
import * as path from 'node:path';
33

4-
import { afterEach, describe, expect, it } from 'vitest';
4+
import { once } from 'storybook/internal/node-logger';
5+
6+
import { afterEach, describe, expect, it, vi } from 'vitest';
57

68
import { dedent } from 'ts-dedent';
79
import ts from 'typescript';
@@ -340,6 +342,140 @@ describe('multi-project management', () => {
340342
expect(project).toBeDefined();
341343
});
342344

345+
it(
346+
'recycles the shared program when heap usage crosses the threshold',
347+
{ timeout: 30_000 },
348+
() => {
349+
tempDir = createTempDir();
350+
351+
const files = writeFiles(tempDir, {
352+
'tsconfig.json': tsconfigJSON(),
353+
'Button.tsx': dedent`
354+
import React from 'react';
355+
export const Button = (_props: { label: string }) => <button />;
356+
`,
357+
'Button.stories.tsx': dedent`
358+
import { Button } from './Button';
359+
export default { component: Button };
360+
`,
361+
});
362+
363+
// ratio 0 → threshold 0 → heapUsed is always ≥ 0 → recycle fires after every batchExtract.
364+
manager = new ComponentMetaManager(ts, 0);
365+
const componentPath = path.join(tempDir, 'Button.tsx');
366+
const before = manager.getProjectForFile(componentPath);
367+
368+
const entries: StoryRef[] = [
369+
{
370+
storyPath: files['Button.stories.tsx'],
371+
component: {
372+
componentName: 'Button',
373+
importName: 'Button',
374+
path: componentPath,
375+
isPackage: false,
376+
},
377+
},
378+
];
379+
manager.batchExtract(entries);
380+
381+
// The disposed program was cleared, so the next lookup rebuilds a fresh instance.
382+
const after = manager.getProjectForFile(componentPath);
383+
expect(after).not.toBe(before);
384+
}
385+
);
386+
387+
it(
388+
'warns once, with guidance to raise the memory limit, when it recycles under pressure',
389+
{ timeout: 30_000 },
390+
() => {
391+
tempDir = createTempDir();
392+
393+
const files = writeFiles(tempDir, {
394+
'tsconfig.json': tsconfigJSON(),
395+
'Button.tsx': dedent`
396+
import React from 'react';
397+
export const Button = (_props: { label: string }) => <button />;
398+
`,
399+
'Button.stories.tsx': dedent`
400+
import { Button } from './Button';
401+
export default { component: Button };
402+
`,
403+
});
404+
405+
// Spy on the one-time `once.warn` channel: routing the warning through it is what makes it
406+
// surface once per process (the dedupe itself is node-logger's responsibility, tested there).
407+
const onceWarnSpy = vi.spyOn(once, 'warn').mockImplementation(() => undefined);
408+
try {
409+
// ratio 0 → threshold 0 → recycle fires on every batchExtract.
410+
manager = new ComponentMetaManager(ts, 0);
411+
const componentPath = path.join(tempDir, 'Button.tsx');
412+
manager.getProjectForFile(componentPath);
413+
414+
const entries: StoryRef[] = [
415+
{
416+
storyPath: files['Button.stories.tsx'],
417+
component: {
418+
componentName: 'Button',
419+
importName: 'Button',
420+
path: componentPath,
421+
isPackage: false,
422+
},
423+
},
424+
];
425+
426+
manager.batchExtract(entries);
427+
428+
expect(onceWarnSpy).toHaveBeenCalled();
429+
expect(onceWarnSpy.mock.calls[0][0]).toContain('--max-old-space-size');
430+
} finally {
431+
onceWarnSpy.mockRestore();
432+
once.clear();
433+
}
434+
}
435+
);
436+
437+
it(
438+
'keeps the shared program while heap usage stays below the threshold',
439+
{ timeout: 30_000 },
440+
() => {
441+
tempDir = createTempDir();
442+
443+
const files = writeFiles(tempDir, {
444+
'tsconfig.json': tsconfigJSON(),
445+
'Button.tsx': dedent`
446+
import React from 'react';
447+
export const Button = (_props: { label: string }) => <button />;
448+
`,
449+
'Button.stories.tsx': dedent`
450+
import { Button } from './Button';
451+
export default { component: Button };
452+
`,
453+
});
454+
455+
// ratio Infinity → threshold Infinity → heapUsed is always below it → recycle never fires.
456+
manager = new ComponentMetaManager(ts, Number.POSITIVE_INFINITY);
457+
const componentPath = path.join(tempDir, 'Button.tsx');
458+
const before = manager.getProjectForFile(componentPath);
459+
460+
const entries: StoryRef[] = [
461+
{
462+
storyPath: files['Button.stories.tsx'],
463+
component: {
464+
componentName: 'Button',
465+
importName: 'Button',
466+
path: componentPath,
467+
isPackage: false,
468+
},
469+
},
470+
];
471+
manager.batchExtract(entries);
472+
473+
// No recycle → the same program instance is reused across extractions.
474+
const after = manager.getProjectForFile(componentPath);
475+
expect(after).toBe(before);
476+
}
477+
);
478+
343479
it('extracts via Path 1 (importId + JSX in story file)', { timeout: 30_000 }, () => {
344480
tempDir = createTempDir();
345481

code/renderers/react/src/componentManifest/componentMeta/ComponentMetaManager.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515
* Plus our own file watching layer (fs.watch + debounce) since we're a standalone Node.js process,
1616
* not running inside an IDE.
1717
*/
18-
import { logger } from 'storybook/internal/node-logger';
18+
import { logger, once } from 'storybook/internal/node-logger';
1919

2020
import { type FSWatcher, existsSync, watch } from 'fs';
2121
import * as path from 'path';
22+
import { dedent } from 'ts-dedent';
2223
import type ts from 'typescript';
24+
import * as v8 from 'v8';
2325

2426
import type { StoryRef } from '../getComponentImports.ts';
2527
import { groupByToMap } from '../utils.ts';
@@ -36,6 +38,12 @@ const DEFAULT_INFERRED_OPTIONS: ts.CompilerOptions = {
3638
skipLibCheck: true,
3739
};
3840

41+
/**
42+
* Recycle (dispose + lazily rebuild) the shared TS program(s) once heap usage crosses this fraction
43+
* of the V8 heap limit.
44+
*/
45+
const RECYCLE_HEAP_PRESSURE_RATIO = 0.7;
46+
3947
export class ComponentMetaManager {
4048
// Adapted from:
4149
// https://github.com/volarjs/volar.js/blob/882cd56d46a13d272f34e451f495d3d62251969a/packages/language-server/lib/project/typescriptProject.ts#L34-L37
@@ -56,7 +64,23 @@ export class ComponentMetaManager {
5664
[number | undefined, ts.IScriptSnapshot | undefined]
5765
>();
5866

59-
constructor(private typescript: typeof ts) {}
67+
// Heap-pressure recycle: bytes of `heapUsed` past which the TS program(s) are disposed and rebuilt.
68+
private readonly heapRecycleThresholdBytes: number;
69+
70+
/**
71+
* @param recycleHeapPressureRatio Fraction of the V8 heap limit at which the shared program(s) are
72+
* recycled (see {@link RECYCLE_HEAP_PRESSURE_RATIO}). Exposed for tuning and for the memory
73+
* regression gate, which passes `Infinity` to disable recycling and assert the OOM still happens
74+
* without the fix. Defaults to {@link RECYCLE_HEAP_PRESSURE_RATIO}.
75+
*/
76+
constructor(
77+
private typescript: typeof ts,
78+
recycleHeapPressureRatio: number = RECYCLE_HEAP_PRESSURE_RATIO
79+
) {
80+
this.heapRecycleThresholdBytes = Math.floor(
81+
v8.getHeapStatistics().heap_size_limit * recycleHeapPressureRatio
82+
);
83+
}
6084

6185
// ---------------------------------------------------------------------------
6286
// Adapted from:
@@ -95,6 +119,39 @@ export class ComponentMetaManager {
95119
logger.debug(`[reactComponentMeta] Batch extraction failed: ${err}`);
96120
}
97121
}
122+
123+
this.recycleProjectsIfHeapPressured();
124+
}
125+
126+
/**
127+
* Reclaim the shared program(s)' resident type-resolution cache when heap usage approaches the V8
128+
* limit, preventing the next extraction's spike from OOMing the dev server.
129+
*/
130+
private recycleProjectsIfHeapPressured(): void {
131+
if (this.configProjects.size === 0 && !this.inferredProject) {
132+
return;
133+
}
134+
if (process.memoryUsage().heapUsed < this.heapRecycleThresholdBytes) {
135+
return;
136+
}
137+
138+
// We crossed the heap-pressure threshold. Recycling reclaims the type cache and usually prevents
139+
// the crash, but a single extraction can still exceed the cap — warn once so the user can raise
140+
// their memory limit rather than silently paying repeated rebuilds (or eventually crashing).
141+
const heapLimitMb = Math.round(v8.getHeapStatistics().heap_size_limit / (1024 * 1024));
142+
once.warn(dedent`
143+
Storybook's experimental docgen server is nearing the Node.js memory limit (~${heapLimitMb} MB) while extracting component types, and recycled its TypeScript program to avoid an out-of-memory crash. This can briefly slow down the docs and Controls panels.
144+
145+
If this happens often, raise Node's memory limit before starting Storybook, for example:
146+
NODE_OPTIONS="--max-old-space-size=${heapLimitMb * 2}"
147+
`);
148+
149+
for (const project of this.configProjects.values()) {
150+
project.dispose();
151+
}
152+
this.inferredProject?.dispose();
153+
this.configProjects.clear();
154+
this.inferredProject = undefined;
98155
}
99156

100157
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)