-
-
Notifications
You must be signed in to change notification settings - Fork 9.8k
Angular: Don't kill dev command by using observables #33185
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
View your CI Pipeline Execution ↗ for commit ef6ccb0
☁️ Nx Cloud last updated this comment at |
📝 WalkthroughWalkthroughConverts the Changes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes
Possibly related PRs
✨ Finishing touches
Tip 📝 Customizable high-level summaries are now available in beta!You can now customize how CodeRabbit generates the high-level summary in your pull requests — including its content, structure, tone, and formatting.
Example instruction:
Note: This feature is currently in beta for Pro-tier users, and pricing will be announced later. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
code/frameworks/angular/src/builders/start-storybook/index.ts(2 hunks)
🧰 Additional context used
📓 Path-based instructions (4)
**/*.{ts,tsx,js,jsx,mjs}
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
Use camelCase for variable and function names
Files:
code/frameworks/angular/src/builders/start-storybook/index.ts
**/*.{ts,tsx}
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
**/*.{ts,tsx}: Enable TypeScript strict mode
Export functions from modules for testing purposes
Files:
code/frameworks/angular/src/builders/start-storybook/index.ts
**/*.{ts,tsx,js,jsx,json,html,mjs}
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
**/*.{ts,tsx,js,jsx,json,html,mjs}: Use ESLint and Prettier for code style enforcement
Run 'yarn prettier --write ' to format code after making changes
Run 'yarn lint:js:cmd ' to check for ESLint issues after making changes
Files:
code/frameworks/angular/src/builders/start-storybook/index.ts
code/**/!(*.test).{ts,tsx,js,mjs}
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
code/**/!(*.test).{ts,tsx,js,mjs}: Use 'logger' from 'storybook/internal/node-logger' for server-side (Node.js) logging, not console.log/console.warn/console.error
Use 'logger' from 'storybook/internal/client-logger' for client-side (browser) logging, not console.log/console.warn/console.error
Do not use console.log, console.warn, or console.error directly unless in isolated files where importing loggers would significantly increase bundle size
Files:
code/frameworks/angular/src/builders/start-storybook/index.ts
🧬 Code graph analysis (1)
code/frameworks/angular/src/builders/start-storybook/index.ts (3)
code/frameworks/angular/src/builders/utils/run-compodoc.ts (1)
runCompodoc(18-45)code/core/src/common/utils/cli.ts (1)
getEnvConfig(69-77)code/core/src/common/js-package-manager/JsPackageManager.ts (1)
packageJson(768-770)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: normal
- GitHub Check: Core Unit Tests, windows-latest
🔇 Additional comments (3)
code/frameworks/angular/src/builders/start-storybook/index.ts (3)
27-27: LGTM! Observable import is necessary.The
rxjsimport is required for the new Observable-based return type and is correctly placed with other imports.
76-94: Excellent fix! Observable pattern correctly prevents premature termination.The conversion from async Promise to Observable with an async IIFE is the right approach. This pattern allows the dev server to remain running by keeping the Observable subscription alive, which is exactly what the PR title indicates was the issue.
The structure correctly:
- Returns
Observable<BuilderOutput>as expected by Angular Architect- Uses an async IIFE to enable
awaitcalls within the Observable- Wraps logic in try-catch to route errors via
observer.error()
183-188: Perfect! This is the core fix that prevents the dev server from being killed.The intentional omission of
observer.complete()is exactly right. By keeping the Observable alive after emitting success, the Angular Architect subscription remains active, which prevents the dev command from terminating prematurely. The inline comment clearly documents this critical behavior.This pattern is standard for long-running Angular builders (like dev servers) that need to remain active for watch mode.
| const commandBuilder: BuilderHandlerFn<StorybookBuilderOptions> = ( | ||
| options, | ||
| context | ||
| ): Promise<BuilderOutput> => { | ||
| logger.intro('Starting Storybook'); | ||
|
|
||
| const { tsConfig } = await setup(options, context); | ||
|
|
||
| const docTSConfig = find.up('tsconfig.doc.json', { | ||
| cwd: options.configDir, | ||
| last: getProjectRoot(), | ||
| }); | ||
|
|
||
| if (options.compodoc) { | ||
| await runCompodoc( | ||
| { | ||
| compodocArgs: [...options.compodocArgs, ...(options.quiet ? ['--silent'] : [])], | ||
| tsconfig: docTSConfig ?? tsConfig, | ||
| }, | ||
| context | ||
| ); | ||
| } | ||
|
|
||
| getEnvConfig(options, { | ||
| port: 'SBCONFIG_PORT', | ||
| host: 'SBCONFIG_HOSTNAME', | ||
| staticDir: 'SBCONFIG_STATIC_DIR', | ||
| configDir: 'SBCONFIG_CONFIG_DIR', | ||
| ci: 'CI', | ||
| ): Observable<BuilderOutput> => { | ||
| return new Observable<BuilderOutput>((observer) => { | ||
| (async () => { | ||
| try { | ||
| logger.intro('Starting Storybook'); | ||
|
|
||
| const { tsConfig } = await setup(options, context); | ||
|
|
||
| const docTSConfig = find.up('tsconfig.doc.json', { | ||
| cwd: options.configDir, | ||
| last: getProjectRoot(), | ||
| }); | ||
|
|
||
| if (options.compodoc) { | ||
| await runCompodoc( | ||
| { | ||
| compodocArgs: [...options.compodocArgs, ...(options.quiet ? ['--silent'] : [])], | ||
| tsconfig: docTSConfig ?? tsConfig, | ||
| }, | ||
| context | ||
| ); | ||
| } | ||
|
|
||
| getEnvConfig(options, { | ||
| port: 'SBCONFIG_PORT', | ||
| host: 'SBCONFIG_HOSTNAME', | ||
| staticDir: 'SBCONFIG_STATIC_DIR', | ||
| configDir: 'SBCONFIG_CONFIG_DIR', | ||
| ci: 'CI', | ||
| }); | ||
|
|
||
| options.port = parseInt(`${options.port}`, 10); | ||
|
|
||
| const { | ||
| browserTarget, | ||
| stylePreprocessorOptions, | ||
| styles, | ||
| ci, | ||
| configDir, | ||
| docs, | ||
| host, | ||
| https, | ||
| port, | ||
| quiet, | ||
| enableProdMode = false, | ||
| smokeTest, | ||
| sslCa, | ||
| sslCert, | ||
| sslKey, | ||
| disableTelemetry, | ||
| assets, | ||
| initialPath, | ||
| open, | ||
| debugWebpack, | ||
| loglevel, | ||
| webpackStatsJson, | ||
| statsJson, | ||
| previewUrl, | ||
| sourceMap = false, | ||
| preserveSymlinks = false, | ||
| experimentalZoneless = !!(VERSION.major && Number(VERSION.major) >= 21), | ||
| } = options; | ||
|
|
||
| const packageJsonPath = pkg.up({ cwd: __dirname }); | ||
| const packageJson = | ||
| packageJsonPath != null ? JSON.parse(readFileSync(packageJsonPath, 'utf8')) : null; | ||
|
|
||
| const standaloneOptions: StandaloneOptions = { | ||
| packageJson, | ||
| ci, | ||
| configDir, | ||
| ...(docs ? { docs } : {}), | ||
| host, | ||
| https, | ||
| port, | ||
| quiet, | ||
| enableProdMode, | ||
| smokeTest, | ||
| sslCa, | ||
| sslCert, | ||
| sslKey, | ||
| disableTelemetry, | ||
| angularBrowserTarget: browserTarget, | ||
| angularBuilderContext: context, | ||
| angularBuilderOptions: { | ||
| ...(stylePreprocessorOptions ? { stylePreprocessorOptions } : {}), | ||
| ...(styles ? { styles } : {}), | ||
| ...(assets ? { assets } : {}), | ||
| preserveSymlinks, | ||
| sourceMap, | ||
| experimentalZoneless, | ||
| }, | ||
| tsConfig, | ||
| initialPath, | ||
| open, | ||
| debugWebpack, | ||
| webpackStatsJson, | ||
| statsJson, | ||
| loglevel, | ||
| previewUrl, | ||
| }; | ||
|
|
||
| const startedPort = await runInstance(standaloneOptions); | ||
|
|
||
| // Emit success output - the dev server is now running | ||
| observer.next({ success: true, info: { port: startedPort } } as BuilderOutput); | ||
|
|
||
| // Don't call observer.complete() - this keeps the Observable alive | ||
| // so the dev server continues running. Architect will keep subscribing | ||
| // until the Observable completes, which allows watch mode to work. | ||
| } catch (error) { | ||
| observer.error(error); | ||
| } | ||
| })(); | ||
| }); | ||
|
|
||
| options.port = parseInt(`${options.port}`, 10); | ||
|
|
||
| const { | ||
| browserTarget, | ||
| stylePreprocessorOptions, | ||
| styles, | ||
| ci, | ||
| configDir, | ||
| docs, | ||
| host, | ||
| https, | ||
| port, | ||
| quiet, | ||
| enableProdMode = false, | ||
| smokeTest, | ||
| sslCa, | ||
| sslCert, | ||
| sslKey, | ||
| disableTelemetry, | ||
| assets, | ||
| initialPath, | ||
| open, | ||
| debugWebpack, | ||
| loglevel, | ||
| webpackStatsJson, | ||
| statsJson, | ||
| previewUrl, | ||
| sourceMap = false, | ||
| preserveSymlinks = false, | ||
| experimentalZoneless = !!(VERSION.major && Number(VERSION.major) >= 21), | ||
| } = options; | ||
|
|
||
| const packageJsonPath = pkg.up({ cwd: __dirname }); | ||
| const packageJson = | ||
| packageJsonPath != null ? JSON.parse(readFileSync(packageJsonPath, 'utf8')) : null; | ||
|
|
||
| const standaloneOptions: StandaloneOptions = { | ||
| packageJson, | ||
| ci, | ||
| configDir, | ||
| ...(docs ? { docs } : {}), | ||
| host, | ||
| https, | ||
| port, | ||
| quiet, | ||
| enableProdMode, | ||
| smokeTest, | ||
| sslCa, | ||
| sslCert, | ||
| sslKey, | ||
| disableTelemetry, | ||
| angularBrowserTarget: browserTarget, | ||
| angularBuilderContext: context, | ||
| angularBuilderOptions: { | ||
| ...(stylePreprocessorOptions ? { stylePreprocessorOptions } : {}), | ||
| ...(styles ? { styles } : {}), | ||
| ...(assets ? { assets } : {}), | ||
| preserveSymlinks, | ||
| sourceMap, | ||
| experimentalZoneless, | ||
| }, | ||
| tsConfig, | ||
| initialPath, | ||
| open, | ||
| debugWebpack, | ||
| webpackStatsJson, | ||
| statsJson, | ||
| loglevel, | ||
| previewUrl, | ||
| }; | ||
|
|
||
| const startedPort = await runInstance(standaloneOptions); | ||
| return { success: true, info: { port: startedPort } } as BuilderOutput; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
Angular Architect builder Observable lifecycle long-running dev server
<web_search_result>
Short summary — how to implement a long-running dev-server builder with Architect (Observable lifecycle):
- Return an Observable (not Promise) from your builder so Architect can keep it subscribed for watch-mode or long-running servers. [1]
- Each build cycle must emit a BuilderOutput object when the run finishes (success/failure). After emitting, the builder may enter a “watching” phase. [1][2]
- While in watch mode, when an external event restarts a build, call context.reportRunning() so Architect knows the builder is active and won’t stop it. [1][2]
- Expose teardown/stop logic so Architect can stop your long-running builder: Architect will call BuilderRun.stop() / unsubscribe the Observable and then run your cleanup. Ensure your Observable’s teardown closes server sockets, watchers and other resources. [1][3]
- Structure your code into three phases: (1) Running — perform the build and emit BuilderOutput; (2) Watching — listen for change events and call reportRunning() before re-entering Running; (3) Completion — respond to stop and clean up. This prevents leaks and makes restarts deterministic. [1][2]
- Practical tips: replay last output if you expose an Observable output, keep Observable hot while watching, and time out or handle unresponsive stop requests in your teardown to avoid hung runs. Use context.reportStatus()/reportProgress() for status updates. [3][1]
Sources:
[1] Angular CLI Builders (official docs) — watch mode, reportRunning, lifecycle.
[2] Community/docs mirrors explaining watch phases and reportRunning.
[3] @angular-devkit/architect / builder typings (BuilderRun/stop/output fields).
</web_search_result>
Script executed:
#!/bin/bash
# Search for other builder implementations using Observable in the angular framework
fd -t f "builder" code/frameworks/angular --exec grep -l "Observable" {} \;Repository: storybookjs/storybook
<shell_result>
Repository: storybookjs/storybook
Exit code: 1
</shell_result>
Script executed:
#!/bin/bash
# Check the runInstance function to understand cleanup behavior
find code/frameworks/angular -name "*.ts" -type f | xargs grep -l "runInstance" | head -5Repository: storybookjs/storybook
<shell_result>
Repository: storybookjs/storybook
Exit code: 0
stdout:
code/frameworks/angular/src/builders/build-storybook/index.ts
code/frameworks/angular/src/builders/start-storybook/index.ts
</shell_result>
Script executed:
#!/bin/bash
# Look for dispose/cleanup patterns in Storybook's Angular builders
rg -A 10 -B 5 "observer\.complete|subscription|unsubscribe|dispose" code/frameworks/angular --type ts | head -100Repository: storybookjs/storybook
<shell_result>
Repository: storybookjs/storybook
Exit code: 0
stdout:
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts- }
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts- }
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts-
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts- ngOnDestroy(): void {
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts- if (this.storyComponentPropsSubscription != null) {
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts: this.storyComponentPropsSubscription.unsubscribe();
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts- }
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts- if (this.storyWrapperPropsSubscription != null) {
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts: this.storyWrapperPropsSubscription.unsubscribe();
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts- }
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts- }
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts- }
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts- return StorybookWrapperComponent;
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts-};
--
code/frameworks/angular/src/client/angular-beta/StorybookProvider.ts- ngZone.run(() => subscriber.complete());
code/frameworks/angular/src/client/angular-beta/StorybookProvider.ts- }
code/frameworks/angular/src/client/angular-beta/StorybookProvider.ts- );
code/frameworks/angular/src/client/angular-beta/StorybookProvider.ts-
code/frameworks/angular/src/client/angular-beta/StorybookProvider.ts- return () => {
code/frameworks/angular/src/client/angular-beta/StorybookProvider.ts: sub.unsubscribe();
code/frameworks/angular/src/client/angular-beta/StorybookProvider.ts- };
code/frameworks/angular/src/client/angular-beta/StorybookProvider.ts- });
code/frameworks/angular/src/client/angular-beta/StorybookProvider.ts-}
--
code/frameworks/angular/src/builders/start-storybook/index.ts- const startedPort = await runInstance(standaloneOptions);
code/frameworks/angular/src/builders/start-storybook/index.ts-
code/frameworks/angular/src/builders/start-storybook/index.ts- // Emit success output - the dev server is now running
code/frameworks/angular/src/builders/start-storybook/index.ts- observer.next({ success: true, info: { port: startedPort } } as BuilderOutput);
code/frameworks/angular/src/builders/start-storybook/index.ts-
code/frameworks/angular/src/builders/start-storybook/index.ts: // Don't call observer.complete() - this keeps the Observable alive
code/frameworks/angular/src/builders/start-storybook/index.ts- // so the dev server continues running. Architect will keep subscribing
code/frameworks/angular/src/builders/start-storybook/index.ts- // until the Observable completes, which allows watch mode to work.
code/frameworks/angular/src/builders/start-storybook/index.ts- } catch (error) {
code/frameworks/angular/src/builders/start-storybook/index.ts- observer.error(error);
code/frameworks/angular/src/builders/start-storybook/index.ts- }
code/frameworks/angular/src/builders/start-storybook/index.ts- })();
code/frameworks/angular/src/builders/start-storybook/index.ts- });
code/frameworks/angular/src/builders/start-storybook/index.ts-};
code/frameworks/angular/src/builders/start-storybook/index.ts-
code/frameworks/angular/src/builders/start-storybook/index.ts-export default createBuilder(commandBuilder) as DevkitBuilder<StorybookBuilderOptions & JsonObject>;
</shell_result>
Script executed:
#!/bin/bash
# Check if there are any comments or documentation about Observable lifecycle
rg -C 5 "dev server|long.running|never.complet" code/frameworks/angular --type tsRepository: storybookjs/storybook
<shell_result>
Repository: storybookjs/storybook
Exit code: 0
stdout:
code/frameworks/angular/src/builders/start-storybook/index.ts- previewUrl,
code/frameworks/angular/src/builders/start-storybook/index.ts- };
code/frameworks/angular/src/builders/start-storybook/index.ts-
code/frameworks/angular/src/builders/start-storybook/index.ts- const startedPort = await runInstance(standaloneOptions);
code/frameworks/angular/src/builders/start-storybook/index.ts-
code/frameworks/angular/src/builders/start-storybook/index.ts: // Emit success output - the dev server is now running
code/frameworks/angular/src/builders/start-storybook/index.ts- observer.next({ success: true, info: { port: startedPort } } as BuilderOutput);
code/frameworks/angular/src/builders/start-storybook/index.ts-
code/frameworks/angular/src/builders/start-storybook/index.ts- // Don't call observer.complete() - this keeps the Observable alive
code/frameworks/angular/src/builders/start-storybook/index.ts: // so the dev server continues running. Architect will keep subscribing
code/frameworks/angular/src/builders/start-storybook/index.ts- // until the Observable completes, which allows watch mode to work.
code/frameworks/angular/src/builders/start-storybook/index.ts- } catch (error) {
code/frameworks/angular/src/builders/start-storybook/index.ts- observer.error(error);
code/frameworks/angular/src/builders/start-storybook/index.ts- }
code/frameworks/angular/src/builders/start-storybook/index.ts- })();
</shell_result>
Script executed:
#!/bin/bash
# Find runInstance implementation
rg -B 5 -A 30 "function runInstance|const runInstance|export.*runInstance" code/frameworks/angular --type tsRepository: storybookjs/storybook
<shell_result>
Repository: storybookjs/storybook
Exit code: 0
stdout:
code/frameworks/angular/src/builders/start-storybook/index.ts- options.tsConfig ??
code/frameworks/angular/src/builders/start-storybook/index.ts- find.up('tsconfig.json', { cwd: options.configDir }) ??
code/frameworks/angular/src/builders/start-storybook/index.ts- browserOptions.tsConfig,
code/frameworks/angular/src/builders/start-storybook/index.ts- };
code/frameworks/angular/src/builders/start-storybook/index.ts-}
code/frameworks/angular/src/builders/start-storybook/index.ts:async function runInstance(options: StandaloneOptions) {
code/frameworks/angular/src/builders/start-storybook/index.ts- try {
code/frameworks/angular/src/builders/start-storybook/index.ts- const { port } = await withTelemetry(
code/frameworks/angular/src/builders/start-storybook/index.ts- 'dev',
code/frameworks/angular/src/builders/start-storybook/index.ts- {
code/frameworks/angular/src/builders/start-storybook/index.ts- cliOptions: options,
code/frameworks/angular/src/builders/start-storybook/index.ts- presetOptions: { ...options, corePresets: [], overridePresets: [] },
code/frameworks/angular/src/builders/start-storybook/index.ts- printError: printErrorDetails,
code/frameworks/angular/src/builders/start-storybook/index.ts- },
code/frameworks/angular/src/builders/start-storybook/index.ts- () => {
code/frameworks/angular/src/builders/start-storybook/index.ts- return buildDevStandalone(options);
code/frameworks/angular/src/builders/start-storybook/index.ts- }
code/frameworks/angular/src/builders/start-storybook/index.ts- );
code/frameworks/angular/src/builders/start-storybook/index.ts- return port;
code/frameworks/angular/src/builders/start-storybook/index.ts- } catch (error) {
code/frameworks/angular/src/builders/start-storybook/index.ts- const summarized = errorSummary(error);
code/frameworks/angular/src/builders/start-storybook/index.ts- throw new Error(String(summarized));
code/frameworks/angular/src/builders/start-storybook/index.ts- }
code/frameworks/angular/src/builders/start-storybook/index.ts-}
--
code/frameworks/angular/src/builders/build-storybook/index.ts- find.up('tsconfig.json', { cwd: options.configDir, last: getProjectRoot() }) ??
code/frameworks/angular/src/builders/build-storybook/index.ts- browserOptions.tsConfig,
code/frameworks/angular/src/builders/build-storybook/index.ts- };
code/frameworks/angular/src/builders/build-storybook/index.ts-}
code/frameworks/angular/src/builders/build-storybook/index.ts-
code/frameworks/angular/src/builders/build-storybook/index.ts:async function runInstance(options: StandaloneBuildOptions) {
code/frameworks/angular/src/builders/build-storybook/index.ts- try {
code/frameworks/angular/src/builders/build-storybook/index.ts- await withTelemetry(
code/frameworks/angular/src/builders/build-storybook/index.ts- 'build',
code/frameworks/angular/src/builders/build-storybook/index.ts- {
code/frameworks/angular/src/builders/build-storybook/index.ts- cliOptions: options,
code/frameworks/angular/src/builders/build-storybook/index.ts- presetOptions: { ...options, corePresets: [], overridePresets: [] },
code/frameworks/angular/src/builders/build-storybook/index.ts- printError: printErrorDetails,
code/frameworks/angular/src/builders/build-storybook/index.ts- },
code/frameworks/angular/src/builders/build-storybook/index.ts- async () => {
code/frameworks/angular/src/builders/build-storybook/index.ts- const result = await buildStaticStandalone(options);
code/frameworks/angular/src/builders/build-storybook/index.ts- return result;
code/frameworks/angular/src/builders/build-storybook/index.ts- }
code/frameworks/angular/src/builders/build-storybook/index.ts- );
code/frameworks/angular/src/builders/build-storybook/index.ts- } catch (error) {
code/frameworks/angular/src/builders/build-storybook/index.ts- const summary = errorSummary(error);
code/frameworks/angular/src/builders/build-storybook/index.ts- throw new Error(summary);
code/frameworks/angular/src/builders/build-storybook/index.ts- }
code/frameworks/angular/src/builders/build-storybook/index.ts-}
</shell_result>
Script executed:
#!/bin/bash
# Check the build-storybook builder for comparison
cat -n code/frameworks/angular/src/builders/build-storybook/index.ts | head -200Repository: storybookjs/storybook
<shell_result>
Repository: storybookjs/storybook
Exit code: 0
stdout:
1 import { readFileSync } from 'node:fs';
2
3 import { getEnvConfig, getProjectRoot, versions } from 'storybook/internal/common';
4 import { buildStaticStandalone, withTelemetry } from 'storybook/internal/core-server';
5 import { addToGlobalContext } from 'storybook/internal/telemetry';
6 import type { CLIOptions } from 'storybook/internal/types';
7 import { logger } from 'storybook/internal/node-logger';
8
9 import type {
10 BuilderContext,
11 BuilderHandlerFn,
12 BuilderOutput,
13 Target,
14 Builder as DevkitBuilder,
15 } from '@angular-devkit/architect';
16 import { createBuilder, targetFromTargetString } from '@angular-devkit/architect';
17 import type {
18 BrowserBuilderOptions,
19 StylePreprocessorOptions,
20 } from '@angular-devkit/build-angular';
21 import type {
22 AssetPattern,
23 SourceMapUnion,
24 StyleElement,
25 } from '@angular-devkit/build-angular/src/builders/browser/schema';
26 import type { JsonObject } from '@angular-devkit/core';
27 import * as find from 'empathic/find';
28 import * as pkg from 'empathic/package';
29
30 import { errorSummary, printErrorDetails } from '../utils/error-handler';
31 import { runCompodoc } from '../utils/run-compodoc';
32 import type { StandaloneOptions } from '../utils/standalone-options';
33 import { VERSION } from '@angular/core';
34
35 addToGlobalContext('cliVersion', versions.storybook);
36
37 export type StorybookBuilderOptions = JsonObject & {
38 browserTarget?: string | null;
39 tsConfig?: string;
40 test: boolean;
41 docs: boolean;
42 compodoc: boolean;
43 compodocArgs: string[];
44 enableProdMode?: boolean;
45 styles?: StyleElement[];
46 stylePreprocessorOptions?: StylePreprocessorOptions;
47 preserveSymlinks?: boolean;
48 assets?: AssetPattern[];
49 sourceMap?: SourceMapUnion;
50 experimentalZoneless?: boolean;
51 } & Pick<
52 // makes sure the option exists
53 CLIOptions,
54 | 'outputDir'
55 | 'configDir'
56 | 'loglevel'
57 | 'quiet'
58 | 'test'
59 | 'webpackStatsJson'
60 | 'statsJson'
61 | 'disableTelemetry'
62 | 'debugWebpack'
63 | 'previewUrl'
64 >;
65
66 export type StorybookBuilderOutput = JsonObject & BuilderOutput & { [key: string]: any };
67
68 type StandaloneBuildOptions = StandaloneOptions & { outputDir: string };
69
70 const commandBuilder: BuilderHandlerFn<StorybookBuilderOptions> = async (
71 options,
72 context
73 ): Promise<BuilderOutput> => {
74 logger.intro('Building Storybook');
75
76 const { tsConfig } = await setup(options, context);
77
78 const docTSConfig = find.up('tsconfig.doc.json', {
79 cwd: options.configDir,
80 last: getProjectRoot(),
81 });
82
83 if (options.compodoc) {
84 await runCompodoc(
85 { compodocArgs: options.compodocArgs, tsconfig: docTSConfig ?? tsConfig },
86 context
87 );
88 }
89
90 getEnvConfig(options, {
91 staticDir: 'SBCONFIG_STATIC_DIR',
92 outputDir: 'SBCONFIG_OUTPUT_DIR',
93 configDir: 'SBCONFIG_CONFIG_DIR',
94 });
95
96 const {
97 browserTarget,
98 stylePreprocessorOptions,
99 styles,
100 configDir,
101 docs,
102 loglevel,
103 test,
104 outputDir,
105 quiet,
106 enableProdMode = true,
107 webpackStatsJson,
108 statsJson,
109 debugWebpack,
110 disableTelemetry,
111 assets,
112 previewUrl,
113 sourceMap = false,
114 preserveSymlinks = false,
115 experimentalZoneless = !!(VERSION.major && Number(VERSION.major) >= 21),
116 } = options;
117
118 const packageJsonPath = pkg.up({ cwd: __dirname });
119 const packageJson =
120 packageJsonPath != null ? JSON.parse(readFileSync(packageJsonPath, 'utf8')) : null;
121
122 const standaloneOptions: StandaloneBuildOptions = {
123 packageJson,
124 configDir,
125 ...(docs ? { docs } : {}),
126 loglevel,
127 outputDir,
128 test,
129 quiet,
130 enableProdMode,
131 disableTelemetry,
132 angularBrowserTarget: browserTarget,
133 angularBuilderContext: context,
134 angularBuilderOptions: {
135 ...(stylePreprocessorOptions ? { stylePreprocessorOptions } : {}),
136 ...(styles ? { styles } : {}),
137 ...(assets ? { assets } : {}),
138 sourceMap,
139 preserveSymlinks,
140 experimentalZoneless,
141 },
142 tsConfig,
143 webpackStatsJson,
144 statsJson,
145 debugWebpack,
146 previewUrl,
147 };
148
149 await runInstance({ ...standaloneOptions, mode: 'static' });
150 logger.outro('Storybook build completed successfully');
151 return { success: true } as BuilderOutput;
152 };
153
154 export default createBuilder(commandBuilder) as DevkitBuilder<StorybookBuilderOptions & JsonObject>;
155
156 async function setup(options: StorybookBuilderOptions, context: BuilderContext) {
157 let browserOptions: (JsonObject & BrowserBuilderOptions) | undefined;
158 let browserTarget: Target | undefined;
159
160 if (options.browserTarget) {
161 browserTarget = targetFromTargetString(options.browserTarget);
162 browserOptions = await context.validateOptions<JsonObject & BrowserBuilderOptions>(
163 await context.getTargetOptions(browserTarget),
164 await context.getBuilderNameForTarget(browserTarget)
165 );
166 }
167
168 return {
169 tsConfig:
170 options.tsConfig ??
171 find.up('tsconfig.json', { cwd: options.configDir, last: getProjectRoot() }) ??
172 browserOptions.tsConfig,
173 };
174 }
175
176 async function runInstance(options: StandaloneBuildOptions) {
177 try {
178 await withTelemetry(
179 'build',
180 {
181 cliOptions: options,
182 presetOptions: { ...options, corePresets: [], overridePresets: [] },
183 printError: printErrorDetails,
184 },
185 async () => {
186 const result = await buildStaticStandalone(options);
187 return result;
188 }
189 );
190 } catch (error) {
191 const summary = errorSummary(error);
192 throw new Error(summary);
193 }
194 }
</shell_result>
Script executed:
#!/bin/bash
# Search for Observable subscription handling and teardown in the builders
rg -B 3 -A 10 "return new Observable" code/frameworks/angular/src/builders --type tsRepository: storybookjs/storybook
<shell_result>
Repository: storybookjs/storybook
Exit code: 0
stdout:
code/frameworks/angular/src/builders/start-storybook/index.ts- options,
code/frameworks/angular/src/builders/start-storybook/index.ts- context
code/frameworks/angular/src/builders/start-storybook/index.ts-): Observable<BuilderOutput> => {
code/frameworks/angular/src/builders/start-storybook/index.ts: return new Observable<BuilderOutput>((observer) => {
code/frameworks/angular/src/builders/start-storybook/index.ts- (async () => {
code/frameworks/angular/src/builders/start-storybook/index.ts- try {
code/frameworks/angular/src/builders/start-storybook/index.ts- logger.intro('Starting Storybook');
code/frameworks/angular/src/builders/start-storybook/index.ts-
code/frameworks/angular/src/builders/start-storybook/index.ts- const { tsConfig } = await setup(options, context);
code/frameworks/angular/src/builders/start-storybook/index.ts-
code/frameworks/angular/src/builders/start-storybook/index.ts- const docTSConfig = find.up('tsconfig.doc.json', {
code/frameworks/angular/src/builders/start-storybook/index.ts- cwd: options.configDir,
code/frameworks/angular/src/builders/start-storybook/index.ts- last: getProjectRoot(),
code/frameworks/angular/src/builders/start-storybook/index.ts- });
</shell_result>
Script executed:
#!/bin/bash
# Check if there's any cleanup/stop logic in the start-storybook file
cat -n code/frameworks/angular/src/builders/start-storybook/index.ts | tail -50Repository: storybookjs/storybook
<shell_result>
Repository: storybookjs/storybook
Exit code: 0
stdout:
186 // Don't call observer.complete() - this keeps the Observable alive
187 // so the dev server continues running. Architect will keep subscribing
188 // until the Observable completes, which allows watch mode to work.
189 } catch (error) {
190 observer.error(error);
191 }
192 })();
193 });
194 };
195
196 export default createBuilder(commandBuilder) as DevkitBuilder<StorybookBuilderOptions & JsonObject>;
197
198 async function setup(options: StorybookBuilderOptions, context: BuilderContext) {
199 let browserOptions: (JsonObject & BrowserBuilderOptions) | undefined;
200 let browserTarget: Target | undefined;
201
202 if (options.browserTarget) {
203 browserTarget = targetFromTargetString(options.browserTarget);
204 browserOptions = await context.validateOptions<JsonObject & BrowserBuilderOptions>(
205 await context.getTargetOptions(browserTarget),
206 await context.getBuilderNameForTarget(browserTarget)
207 );
208 }
209
210 return {
211 tsConfig:
212 options.tsConfig ??
213 find.up('tsconfig.json', { cwd: options.configDir }) ??
214 browserOptions.tsConfig,
215 };
216 }
217 async function runInstance(options: StandaloneOptions) {
218 try {
219 const { port } = await withTelemetry(
220 'dev',
221 {
222 cliOptions: options,
223 presetOptions: { ...options, corePresets: [], overridePresets: [] },
224 printError: printErrorDetails,
225 },
226 () => {
227 return buildDevStandalone(options);
228 }
229 );
230 return port;
231 } catch (error) {
232 const summarized = errorSummary(error);
233 throw new Error(String(summarized));
234 }
235 }
</shell_result>
Script executed:
#!/bin/bash
# Search for buildDevStandalone to understand if it manages its own cleanup
rg -B 5 -A 20 "buildDevStandalone" code --type ts | head -100Repository: storybookjs/storybook
<shell_result>
Repository: storybookjs/storybook
Exit code: 0
stdout:
code/frameworks/angular/src/builders/start-storybook/index.ts-import { readFileSync } from 'node:fs';
code/frameworks/angular/src/builders/start-storybook/index.ts-
code/frameworks/angular/src/builders/start-storybook/index.ts-import { getEnvConfig, getProjectRoot, versions } from 'storybook/internal/common';
code/frameworks/angular/src/builders/start-storybook/index.ts:import { buildDevStandalone, withTelemetry } from 'storybook/internal/core-server';
code/frameworks/angular/src/builders/start-storybook/index.ts-import { addToGlobalContext } from 'storybook/internal/telemetry';
code/frameworks/angular/src/builders/start-storybook/index.ts-import type { CLIOptions } from 'storybook/internal/types';
code/frameworks/angular/src/builders/start-storybook/index.ts-import { logger } from 'storybook/internal/node-logger';
code/frameworks/angular/src/builders/start-storybook/index.ts-
code/frameworks/angular/src/builders/start-storybook/index.ts-import type {
code/frameworks/angular/src/builders/start-storybook/index.ts- BuilderContext,
code/frameworks/angular/src/builders/start-storybook/index.ts- BuilderHandlerFn,
code/frameworks/angular/src/builders/start-storybook/index.ts- BuilderOutput,
code/frameworks/angular/src/builders/start-storybook/index.ts- Target,
code/frameworks/angular/src/builders/start-storybook/index.ts- Builder as DevkitBuilder,
code/frameworks/angular/src/builders/start-storybook/index.ts-} from '@angular-devkit/architect';
code/frameworks/angular/src/builders/start-storybook/index.ts-import { createBuilder, targetFromTargetString } from '@angular-devkit/architect';
code/frameworks/angular/src/builders/start-storybook/index.ts-import type {
code/frameworks/angular/src/builders/start-storybook/index.ts- BrowserBuilderOptions,
code/frameworks/angular/src/builders/start-storybook/index.ts- StylePreprocessorOptions,
code/frameworks/angular/src/builders/start-storybook/index.ts-} from '@angular-devkit/build-angular';
code/frameworks/angular/src/builders/start-storybook/index.ts-import type {
code/frameworks/angular/src/builders/start-storybook/index.ts- AssetPattern,
code/frameworks/angular/src/builders/start-storybook/index.ts- SourceMapUnion,
code/frameworks/angular/src/builders/start-storybook/index.ts- StyleElement,
--
code/frameworks/angular/src/builders/start-storybook/index.ts- cliOptions: options,
code/frameworks/angular/src/builders/start-storybook/index.ts- presetOptions: { ...options, corePresets: [], overridePresets: [] },
code/frameworks/angular/src/builders/start-storybook/index.ts- printError: printErrorDetails,
code/frameworks/angular/src/builders/start-storybook/index.ts- },
code/frameworks/angular/src/builders/start-storybook/index.ts- () => {
code/frameworks/angular/src/builders/start-storybook/index.ts: return buildDevStandalone(options);
code/frameworks/angular/src/builders/start-storybook/index.ts- }
code/frameworks/angular/src/builders/start-storybook/index.ts- );
code/frameworks/angular/src/builders/start-storybook/index.ts- return port;
code/frameworks/angular/src/builders/start-storybook/index.ts- } catch (error) {
code/frameworks/angular/src/builders/start-storybook/index.ts- const summarized = errorSummary(error);
code/frameworks/angular/src/builders/start-storybook/index.ts- throw new Error(String(summarized));
code/frameworks/angular/src/builders/start-storybook/index.ts- }
code/frameworks/angular/src/builders/start-storybook/index.ts-}
--
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-import { TestingArchitectHost } from '@angular-devkit/architect/testing';
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-import { schema } from '@angular-devkit/core';
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-import { join } from 'node:path';
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-
code/frameworks/angular/src/builders/start-storybook/index.spec.ts:const buildDevStandaloneMock = vi.fn();
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-const buildStaticStandaloneMock = vi.fn();
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-const buildMock = {
code/frameworks/angular/src/builders/start-storybook/index.spec.ts: buildDevStandalone: buildDevStandaloneMock,
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- buildStaticStandalone: buildStaticStandaloneMock,
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- withTelemetry: (_: string, __: any, fn: any) => fn(),
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-};
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-vi.doMock('storybook/internal/core-server', () => buildMock);
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-vi.doMock('empathic/find', () => ({ up: () => './storybook/tsconfig.ts' }));
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-const mockRunScript = vi.fn();
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-vi.mock('storybook/internal/common', () => ({
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- getEnvConfig: (options: any) => options,
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- versions: {
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- storybook: 'x.x.x',
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- },
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- JsPackageManagerFactory: {
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- getPackageManager: () => ({
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- runPackageCommand: mockRunScript,
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- }),
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- },
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-}));
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-
--
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- // for the package.json file.
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- await architectHost.addBuilderFromPackage(join(__dirname, '../../..'));
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- });
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- beforeEach(() => {
code/frameworks/angular/src/builders/start-storybook/index.spec.ts: buildDevStandaloneMock.mockImplementation((_options: unknown) => Promise.resolve(_options));
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- });
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- afterEach(() => {
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- vi.clearAllMocks();
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- });
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- it('should start storybook with angularBrowserTarget', async () => {
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- const run = await architect.scheduleBuilder('@storybook/angular:start-storybook', {
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- browserTarget: 'angular-cli:build-2',
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- port: 4400,
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- compodoc: false,
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- });
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- const output = await run.result;
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- await run.stop();
code/frameworks/angular/src/builders/start-storybook/index.spec.ts-
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- expect(output.success).toBeTruthy();
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- expect(mockRunScript).not.toHaveBeenCalledWith();
code/frameworks/angular/src/builders/start-storybook/index.spec.ts: expect(buildDevStandaloneMock).toHaveBeenCalledWith(
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- expect.objectContaining({
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- angularBrowserTarget: 'angular-cli:build-2',
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- angularBuilderContext: expect.any(Object),
code/frameworks/angular/src/builders/start-storybook/index.spec.ts- ci: false,
</shell_result>
Script executed:
#!/bin/bash
# Check if there are any process signal handlers in the builders
rg "SIGINT|SIGTERM|process\.on|\.catch|\.finally" code/frameworks/angular/src/builders --type ts -C 3Repository: storybookjs/storybook
<shell_result>
Repository: storybookjs/storybook
Exit code: 1
</shell_result>
Script executed:
#!/bin/bash
# Look for how other Storybook features handle cleanup/teardown
rg -B 3 -A 10 "teardown|cleanup|dispose|kill|terminate" code/frameworks/angular --type ts | head -80Repository: storybookjs/storybook
<shell_result>
Repository: storybookjs/storybook
Exit code: 0
</shell_result>
Script executed:
#!/bin/bash
# Check if there's documentation about the dev server lifecycle
find code -name "*.md" -o -name "*.txt" | xargs grep -l "dev.*server\|long.*running" 2>/dev/null | head -5Repository: storybookjs/storybook
<shell_result>
Repository: storybookjs/storybook
Exit code: 0
stdout:
code/addons/docs/docs/faq.md
code/builders/builder-vite/README.md
code/core/README.md
code/core/src/core-server/README.md
code/core/src/instrumenter/README.md
</shell_result>
Script executed:
#!/bin/bash
# Look at the complete test to see how stop() is used
cat -n code/frameworks/angular/src/builders/start-storybook/index.spec.ts | sed -n '30,120p'Repository: storybookjs/storybook
<shell_result>
Repository: storybookjs/storybook
Exit code: 0
stdout:
30
31 // Randomly fails on CI. TODO: investigate why
32 describe.skip('Start Storybook Builder', () => {
33 let architect: Architect;
34 let architectHost: TestingArchitectHost;
35
36 beforeEach(async () => {
37 const registry = new schema.CoreSchemaRegistry();
38 registry.addPostTransform(schema.transforms.addUndefinedDefaults);
39
40 architectHost = new TestingArchitectHost();
41 architect = new Architect(architectHost, registry);
42
43 architectHost.addBuilder(
44 '@angular-devkit/build-angular:browser',
45 createBuilder(() => {
46 return { success: true };
47 })
48 );
49 architectHost.addTarget(
50 { project: 'angular-cli', target: 'build-2' },
51 '@angular-devkit/build-angular:browser',
52 {
53 outputPath: 'dist/angular-cli',
54 index: 'src/index.html',
55 main: 'src/main.ts',
56 polyfills: 'src/polyfills.ts',
57 tsConfig: 'src/tsconfig.app.json',
58 assets: ['src/favicon.ico', 'src/assets'],
59 styles: ['src/styles.css'],
60 scripts: [],
61 }
62 );
63 // This will either take a Node package name, or a path to the directory
64 // for the package.json file.
65 await architectHost.addBuilderFromPackage(join(__dirname, '../../..'));
66 });
67
68 beforeEach(() => {
69 buildDevStandaloneMock.mockImplementation((_options: unknown) => Promise.resolve(_options));
70 });
71
72 afterEach(() => {
73 vi.clearAllMocks();
74 });
75
76 it('should start storybook with angularBrowserTarget', async () => {
77 const run = await architect.scheduleBuilder('@storybook/angular:start-storybook', {
78 browserTarget: 'angular-cli:build-2',
79 port: 4400,
80 compodoc: false,
81 });
82
83 const output = await run.result;
84
85 await run.stop();
86
87 expect(output.success).toBeTruthy();
88 expect(mockRunScript).not.toHaveBeenCalledWith();
89 expect(buildDevStandaloneMock).toHaveBeenCalledWith(
90 expect.objectContaining({
91 angularBrowserTarget: 'angular-cli:build-2',
92 angularBuilderContext: expect.any(Object),
93 ci: false,
94 configDir: '.storybook',
95 disableTelemetry: undefined,
96 host: 'localhost',
97 https: false,
98 packageJson: expect.any(Object),
99 port: 4400,
100 quiet: false,
101 smokeTest: false,
102 sslCa: undefined,
103 sslCert: undefined,
104 sslKey: undefined,
105 tsConfig: './storybook/tsconfig.ts',
106 })
107 );
108 });
109
110 it('should start storybook with tsConfig', async () => {
111 const run = await architect.scheduleBuilder('@storybook/angular:start-storybook', {
112 tsConfig: 'path/to/tsConfig.json',
113 port: 4400,
114 compodoc: false,
115 });
116
117 const output = await run.result;
118
119 await run.stop();
120
</shell_result>
Script executed:
#!/bin/bash
# Search for Observable subscription handling patterns in Angular Architect examples
rg -B 5 -A 15 "return new Observable.*observer.*=>" code --type ts | head -150Repository: storybookjs/storybook
<shell_result>
Repository: storybookjs/storybook
Exit code: 0
stdout:
code/frameworks/angular/src/builders/start-storybook/index.ts-
code/frameworks/angular/src/builders/start-storybook/index.ts-const commandBuilder: BuilderHandlerFn<StorybookBuilderOptions> = (
code/frameworks/angular/src/builders/start-storybook/index.ts- options,
code/frameworks/angular/src/builders/start-storybook/index.ts- context
code/frameworks/angular/src/builders/start-storybook/index.ts-): Observable<BuilderOutput> => {
code/frameworks/angular/src/builders/start-storybook/index.ts: return new Observable<BuilderOutput>((observer) => {
code/frameworks/angular/src/builders/start-storybook/index.ts- (async () => {
code/frameworks/angular/src/builders/start-storybook/index.ts- try {
code/frameworks/angular/src/builders/start-storybook/index.ts- logger.intro('Starting Storybook');
code/frameworks/angular/src/builders/start-storybook/index.ts-
code/frameworks/angular/src/builders/start-storybook/index.ts- const { tsConfig } = await setup(options, context);
code/frameworks/angular/src/builders/start-storybook/index.ts-
code/frameworks/angular/src/builders/start-storybook/index.ts- const docTSConfig = find.up('tsconfig.doc.json', {
code/frameworks/angular/src/builders/start-storybook/index.ts- cwd: options.configDir,
code/frameworks/angular/src/builders/start-storybook/index.ts- last: getProjectRoot(),
code/frameworks/angular/src/builders/start-storybook/index.ts- });
code/frameworks/angular/src/builders/start-storybook/index.ts-
code/frameworks/angular/src/builders/start-storybook/index.ts- if (options.compodoc) {
code/frameworks/angular/src/builders/start-storybook/index.ts- await runCompodoc(
code/frameworks/angular/src/builders/start-storybook/index.ts- {
code/frameworks/angular/src/builders/start-storybook/index.ts- compodocArgs: [...options.compodocArgs, ...(options.quiet ? ['--silent'] : [])],
</shell_result>
Script executed:
#!/bin/bash
# Check if there are any examples of Observable cleanup/unsubscribe in the codebase
rg "subscription|unsubscribe" code/frameworks/angular --type ts -B 3 -A 3Repository: storybookjs/storybook
<shell_result>
Repository: storybookjs/storybook
Exit code: 0
stdout:
code/frameworks/angular/src/client/angular-beta/StorybookProvider.ts- );
code/frameworks/angular/src/client/angular-beta/StorybookProvider.ts-
code/frameworks/angular/src/client/angular-beta/StorybookProvider.ts- return () => {
code/frameworks/angular/src/client/angular-beta/StorybookProvider.ts: sub.unsubscribe();
code/frameworks/angular/src/client/angular-beta/StorybookProvider.ts- };
code/frameworks/angular/src/client/angular-beta/StorybookProvider.ts- });
code/frameworks/angular/src/client/angular-beta/StorybookProvider.ts-}
--
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts-
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts- ngOnDestroy(): void {
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts- if (this.storyComponentPropsSubscription != null) {
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts: this.storyComponentPropsSubscription.unsubscribe();
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts- }
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts- if (this.storyWrapperPropsSubscription != null) {
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts: this.storyWrapperPropsSubscription.unsubscribe();
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts- }
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts- }
code/frameworks/angular/src/client/angular-beta/StorybookWrapperComponent.ts- }
</shell_result>
Angular Architect builder Observable subscription cleanup teardown function
<web_search_result>
Short answer
- When an Architect (Angular CLI) builder returns an Observable, Architect subscribes to it and will call the Observable’s teardown when the run is stopped (e.g., BuilderRun.stop()). Put any cleanup/unsubscribe logic in the Observable’s teardown function (the function you return from new Observable(...)). [1][2]
Example
-
Minimal pattern to ensure proper cleanup:
const myBuilder = createBuilder((options, context) => {
return new Observable(observer => {
const sub = someHotObservable.subscribe(value => {
// emit a BuilderOutput when appropriate
observer.next({ success: true });
}, err => observer.error(err));// when Architect unsubscribes (or the Observable completes/error), this teardown runs return () => { sub.unsubscribe(); // other cleanup (close watchers, stop servers, etc.) context.logger.info('builder teardown ran'); };});
});
Notes
- Call context.reportRunning() if your builder enters watch mode between runs so Architect knows it’s still active. Architect will unsubscribe and run your teardown when the run is stopped. [1][2]
Sources
[1] Angular CLI builders (official docs).
[2] createBuilder / CLI builder behavior (Architect teardown description).
</web_search_result>
Script executed:
#!/bin/bash
# Check what buildDevStandalone returns to understand what needs cleanup
rg -B 5 -A 20 "export.*buildDevStandalone|function buildDevStandalone" code/core --type ts | head -60Repository: storybookjs/storybook
<shell_result>
Repository: storybookjs/storybook
Exit code: 0
stdout:
code/core/src/core-server/build-dev.ts-import { getServerChannelUrl, getServerPort } from './utils/server-address';
code/core/src/core-server/build-dev.ts-import { updateCheck } from './utils/update-check';
code/core/src/core-server/build-dev.ts-import { warnOnIncompatibleAddons } from './utils/warnOnIncompatibleAddons';
code/core/src/core-server/build-dev.ts-import { warnWhenUsingArgTypesRegex } from './utils/warnWhenUsingArgTypesRegex';
code/core/src/core-server/build-dev.ts-
code/core/src/core-server/build-dev.ts:export async function buildDevStandalone(
code/core/src/core-server/build-dev.ts- options: CLIOptions &
code/core/src/core-server/build-dev.ts- LoadOptions &
code/core/src/core-server/build-dev.ts- BuilderOptions & {
code/core/src/core-server/build-dev.ts- storybookVersion?: string;
code/core/src/core-server/build-dev.ts- previewConfigPath?: string;
code/core/src/core-server/build-dev.ts- }
code/core/src/core-server/build-dev.ts-): Promise<{ port: number; address: string; networkAddress: string }> {
code/core/src/core-server/build-dev.ts- const { packageJson, versionUpdates } = options;
code/core/src/core-server/build-dev.ts- let { storybookVersion, previewConfigPath } = options;
code/core/src/core-server/build-dev.ts- const configDir = resolve(options.configDir);
code/core/src/core-server/build-dev.ts- if (packageJson) {
code/core/src/core-server/build-dev.ts- invariant(
code/core/src/core-server/build-dev.ts- packageJson.version !== undefined,
code/core/src/core-server/build-dev.ts- `Expected package.json#version to be defined in the "${packageJson.name}" package}`
code/core/src/core-server/build-dev.ts- );
code/core/src/core-server/build-dev.ts- storybookVersion = packageJson.version;
code/core/src/core-server/build-dev.ts- previewConfigPath = getConfigInfo(configDir).previewConfigPath ?? undefined;
code/core/src/core-server/build-dev.ts- } else {
code/core/src/core-server/build-dev.ts- if (!storybookVersion) {
code/core/src/core-server/build-dev.ts- storybookVersion = versions.storybook;
</shell_result>
Script executed:
#!/bin/bash
# Look for how buildDevStandalone handles process termination
cat code/core/src/core-server/build-dev.ts | head -150 | tail -100Repository: storybookjs/storybook
<shell_result>
Repository: storybookjs/storybook
Exit code: 0
stdout:
packageJson.version !== undefined,
`Expected package.json#version to be defined in the "${packageJson.name}" package}`
);
storybookVersion = packageJson.version;
previewConfigPath = getConfigInfo(configDir).previewConfigPath ?? undefined;
} else {
if (!storybookVersion) {
storybookVersion = versions.storybook;
}
}
// updateInfo are cached, so this is typically pretty fast
const [port, versionCheck] = await Promise.all([
getServerPort(options.port, { exactPort: options.exactPort }),
versionUpdates
? updateCheck(storybookVersion)
: Promise.resolve({ success: false, cached: false, data: {}, time: Date.now() }),
]);
if (!options.ci && !options.smokeTest && options.port != null && port !== options.port) {
const shouldChangePort = await prompt.confirm({
message: dedent`
Port ${options.port} is not available.
Would you like to run Storybook on port ${port} instead?
`,
initialValue: true,
});
if (!shouldChangePort) {
process.exit(1);
}
}
const cacheKey = oneWayHash(relative(getProjectRoot(), configDir));
const cacheOutputDir = resolvePathInStorybookCache('public', cacheKey);
let outputDir = resolve(options.outputDir || cacheOutputDir);
if (options.smokeTest) {
outputDir = cacheOutputDir;
}
options.port = port;
options.versionCheck = versionCheck;
options.configType = 'DEVELOPMENT';
options.configDir = configDir;
options.cacheKey = cacheKey;
options.outputDir = outputDir;
options.serverChannelUrl = getServerChannelUrl(port, options);
// TODO: Remove in SB11
options.pnp = await detectPnp();
if (options.pnp) {
deprecate(dedent`
As of Storybook 10.0, PnP is deprecated.
If you are using PnP, you can continue to use Storybook 10.0, but we recommend migrating to a different package manager or linker-mode.
In future versions, PnP compatibility will be removed.
`);
}
const config = await loadMainConfig(options);
const { framework } = config;
const corePresets = [];
let frameworkName = typeof framework === 'string' ? framework : framework?.name;
if (!options.ignorePreview) {
validateFrameworkName(frameworkName);
}
if (frameworkName) {
corePresets.push(join(frameworkName, 'preset'));
}
frameworkName = frameworkName || 'custom';
const packageManager = JsPackageManagerFactory.getPackageManager({
configDir: options.configDir,
});
try {
await warnOnIncompatibleAddons(storybookVersion, packageManager);
} catch (e) {
logger.warn('Storybook failed to check addon compatibility');
logger.debug(`${e instanceof Error ? e.stack : String(e)}`);
}
// TODO: Bring back in 9.x when we officialy launch CSF4
// We need to consider more scenarios in this function, such as removing addons from main.ts
// try {
// await syncStorybookAddons(config, previewConfigPath!);
// } catch (e) {}
try {
await warnWhenUsingArgTypesRegex(previewConfigPath, config);
} catch (e) {}
// Load first pass: We need to determine the builder
// We need to do this because builders might introduce 'overridePresets' which we need to take into account
// We hope to remove this in SB8
let presets = await loadAllPresets({
corePresets,
overridePresets: [
import.meta.resolve('storybook/internal/core-server/presets/common-override-preset'),
</shell_result>
Script executed:
#!/bin/bash
# Search for SIGINT or process signal handling in build-dev.ts
rg "SIGINT|SIGTERM|process\.on|\.listen|server\." code/core/src/core-server/build-dev.ts -B 2 -A 5 | head -100Repository: storybookjs/storybook
<shell_result>
Repository: storybookjs/storybook
Exit code: 0
</shell_result>
Script executed:
#!/bin/bash
# Check how Express app.listen works in context - does it block or return immediately
rg "app\.listen|server\.listen" code/core/src/core-server --type ts -B 3 -A 10 | head -80Repository: storybookjs/storybook
<shell_result>
Repository: storybookjs/storybook
Exit code: 0
stdout:
code/core/src/core-server/dev-server.ts-
code/core/src/core-server/dev-server.ts- const listening = new Promise<void>((resolve, reject) => {
code/core/src/core-server/dev-server.ts- server.once('error', reject);
code/core/src/core-server/dev-server.ts: app.listen({ port, host }, resolve);
code/core/src/core-server/dev-server.ts- });
code/core/src/core-server/dev-server.ts-
code/core/src/core-server/dev-server.ts- await Promise.all([initializedStoryIndexGenerator, listening]).then(async ([indexGenerator]) => {
code/core/src/core-server/dev-server.ts- if (indexGenerator && !options.ci && !options.smokeTest && options.open) {
code/core/src/core-server/dev-server.ts- const url = host ? networkAddress : address;
code/core/src/core-server/dev-server.ts- openInBrowser(options.previewOnly ? `${url}iframe.html?navigator=true` : url).catch(() => {
code/core/src/core-server/dev-server.ts- // the browser window could not be opened, this is non-critical, we just ignore the error
code/core/src/core-server/dev-server.ts- });
code/core/src/core-server/dev-server.ts- }
code/core/src/core-server/dev-server.ts- });
</shell_result>
Script executed:
#!/bin/bash
# Check if there's any process cleanup in the Angular builder or if it relies on signals
rg -B 10 -A 10 "process\.exit|process\.kill|process\.on.*SIGINT" code/core/src/core-server/build-dev.tsRepository: storybookjs/storybook
<shell_result>
Repository: storybookjs/storybook
Exit code: 0
stdout:
if (!options.ci && !options.smokeTest && options.port != null && port !== options.port) {
const shouldChangePort = await prompt.confirm({
message: dedent`
Port ${options.port} is not available.
Would you like to run Storybook on port ${port} instead?
`,
initialValue: true,
});
if (!shouldChangePort) {
process.exit(1);
}
}
const cacheKey = oneWayHash(relative(getProjectRoot(), configDir));
const cacheOutputDir = resolvePathInStorybookCache('public', cacheKey);
let outputDir = resolve(options.outputDir || cacheOutputDir);
if (options.smokeTest) {
outputDir = cacheOutputDir;
}
--
}
const problems = warnings
.filter((warning) => !warning.message.includes(`export 'useInsertionEffect'`))
.filter((warning) => !warning.message.includes(`compilation but it's unused`))
.filter(
(warning) => !warning.message.includes(`Conflicting values for 'process.env.NODE_ENV'`)
);
logger.log(problems.map((p) => p.stack).join('\n'));
process.exit(problems.length > 0 ? 1 : 0);
} else {
const name =
frameworkName.split('@storybook/').length > 1
? frameworkName.split('@storybook/')[1]
: frameworkName;
if (!options.quiet) {
outputStartupInformation({
updateInfo: versionCheck,
version: storybookVersion,
</shell_result>
Add Observable teardown function to gracefully stop the dev server when the builder is stopped.
The implementation starts a dev server via buildDevStandalone() which resolves after the server is listening, then emits a successful BuilderOutput and keeps the Observable alive. However, when Architect calls run.stop() or the Observable is unsubscribed, there is no teardown function to gracefully shut down the server process. This leaves the Node.js dev server running as an orphaned process.
According to Angular Architect's documented patterns, provide a teardown function that stops the dev server:
return new Observable<BuilderOutput>((observer) => {
let serverProcess: any;
(async () => {
try {
// ... existing setup code ...
const startedPort = await runInstance(standaloneOptions);
observer.next({ success: true, info: { port: startedPort } } as BuilderOutput);
} catch (error) {
observer.error(error);
}
})();
// Teardown called when run.stop() is invoked by Architect
return () => {
// Gracefully terminate the dev server process
if (serverProcess) {
serverProcess.kill();
}
};
});Note: buildDevStandalone() needs to be modified to return a handle (e.g., the server instance or process) that can be terminated in the teardown function.
Closes #
What I did
Checklist for Contributors
Testing
The changes in this PR are covered in the following automated tests:
Manual testing
This section is mandatory for all contributions. If you believe no manual test is necessary, please state so explicitly. Thanks!
Documentation
MIGRATION.MD
Checklist for Maintainers
When this PR is ready for testing, make sure to add
ci:normal,ci:mergedorci:dailyGH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found incode/lib/cli-storybook/src/sandbox-templates.tsMake sure this PR contains one of the labels below:
Available labels
bug: Internal changes that fixes incorrect behavior.maintenance: User-facing maintenance tasks.dependencies: Upgrading (sometimes downgrading) dependencies.build: Internal-facing build tooling & test updates. Will not show up in release changelog.cleanup: Minor cleanup style change. Will not show up in release changelog.documentation: Documentation only changes. Will not show up in release changelog.feature request: Introducing a new feature.BREAKING CHANGE: Changes that break compatibility in some way with current major version.other: Changes that don't fit in the above categories.🦋 Canary release
This PR does not have a canary release associated. You can request a canary release of this pull request by mentioning the
@storybookjs/coreteam here.core team members can create a canary release here or locally with
gh workflow run --repo storybookjs/storybook publish.yml --field pr=<PR_NUMBER>Summary by CodeRabbit
✏️ Tip: You can customize this high-level summary in your review settings.