Skip to content
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

Use Angular Providers in boostrapApplication option #20746

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { NgModule, enableProdMode, Type, ApplicationRef } from '@angular/core';
import { ApplicationRef, enableProdMode, NgModule } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideAnimations, BrowserAnimationsModule } from '@angular/platform-browser/animations';

import { Subject, BehaviorSubject } from 'rxjs';
import { BehaviorSubject, Subject } from 'rxjs';
import { stringify } from 'telejson';
import { ICollection, StoryFnAngularReturnType, Parameters } from '../types';
import { ICollection, Parameters, StoryFnAngularReturnType } from '../types';
import { getApplication } from './StorybookModule';
import { storyPropsProvider } from './StorybookProvider';
import { componentNgModules } from './StorybookWrapperComponent';
Expand Down Expand Up @@ -134,8 +134,8 @@ export abstract class AbstractRenderer {

const applicationRef = await bootstrapApplication(application, {
providers: [
storyPropsProvider(newStoryProps$),
...(hasAnimationsDefined ? [provideAnimations()] : []),
storyPropsProvider(newStoryProps$),
],
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,22 @@
import { CommonModule } from '@angular/common';
import {
AfterViewInit,
ElementRef,
OnDestroy,
Type,
ChangeDetectorRef,
Component,
ElementRef,
Inject,
NgModule,
OnDestroy,
Type,
ViewChild,
ViewContainerRef,
NgModule,
} from '@angular/core';
import { Subscription, Subject } from 'rxjs';
import { Subject, Subscription } from 'rxjs';
import { map, skip } from 'rxjs/operators';

import { ICollection, NgModuleMetadata } from '../types';
import { STORY_PROPS } from './StorybookProvider';
import {
ComponentInputsOutputs,
getComponentInputsOutputs,
isDeclarable,
isStandaloneComponent,
} from './utils/NgComponentAnalyzer';
import { isComponentAlreadyDeclaredInModules } from './utils/NgModulesAnalyzer';
import { ComponentInputsOutputs, getComponentInputsOutputs } from './utils/NgComponentAnalyzer';
import { extractDeclarations, extractImports, extractProviders } from './utils/PropertyExtractor';

const getNonInputsOutputsProps = (
ngComponentInputsOutputs: ComponentInputsOutputs,
Expand All @@ -37,6 +31,7 @@ const getNonInputsOutputsProps = (
return Object.keys(props).filter((k) => ![...inputs, ...outputs].includes(k));
};

// component modules cache
export const componentNgModules = new Map<any, Type<any>>();

/**
Expand All @@ -57,56 +52,33 @@ export const createStorybookWrapperComponent = (
// storyComponent was not provided.
const viewChildSelector = storyComponent ?? '__storybook-noop';

const isStandalone = isStandaloneComponent(storyComponent);
// Look recursively (deep) if the component is not already declared by an import module
const requiresComponentDeclaration =
isDeclarable(storyComponent) &&
!isComponentAlreadyDeclaredInModules(
storyComponent,
moduleMetadata.declarations,
moduleMetadata.imports
) &&
!isStandalone;

const providersNgModules = (moduleMetadata.providers ?? []).map((provider) => {
if (!componentNgModules.get(provider)) {
@NgModule({
providers: [provider],
})
class ProviderModule {}

componentNgModules.set(provider, ProviderModule);
}

return componentNgModules.get(provider);
});

if (!componentNgModules.get(storyComponent)) {
const declarations = [
...(requiresComponentDeclaration ? [storyComponent] : []),
...(moduleMetadata.declarations ?? []),
];
const imports = extractImports(moduleMetadata);
const declarations = extractDeclarations(moduleMetadata, storyComponent);
const providers = extractProviders(moduleMetadata);

// Only create a new module if it doesn't already exist
// This is to prevent the module from being recreated on every story change
// Declarations & Imports are only added once
// Providers are added on every story change to allow for story-specific providers
let ngModule = componentNgModules.get(storyComponent);
if (!ngModule) {
@NgModule({
declarations,
imports: [CommonModule, ...(moduleMetadata.imports ?? [])],
exports: [...declarations, ...(moduleMetadata.imports ?? [])],
imports,
exports: [...declarations, ...imports],
})
class StorybookComponentModule {}

componentNgModules.set(storyComponent, StorybookComponentModule);
ngModule = componentNgModules.get(storyComponent);
}

@Component({
selector,
template,
standalone: true,
imports: [
CommonModule,
componentNgModules.get(storyComponent),
...providersNgModules,
...(isStandalone ? [storyComponent] : []),
],
imports: [ngModule],
providers,
styles,
schemas: moduleMetadata.schemas,
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Component, NgModule } from '@angular/core';
import { isComponentAlreadyDeclaredInModules } from './NgModulesAnalyzer';
import { isComponentAlreadyDeclared } from './NgModulesAnalyzer';

const FooComponent = Component({})(class {});

Expand All @@ -11,16 +11,14 @@ const AlphaModule = NgModule({ imports: [BetaModule] })(class {});

describe('isComponentAlreadyDeclaredInModules', () => {
it('should return true when the component is already declared in one of modules', () => {
expect(isComponentAlreadyDeclaredInModules(FooComponent, [], [AlphaModule])).toEqual(true);
expect(isComponentAlreadyDeclared(FooComponent, [], [AlphaModule])).toEqual(true);
});

it('should return true if the component is in moduleDeclarations', () => {
expect(
isComponentAlreadyDeclaredInModules(BarComponent, [BarComponent], [AlphaModule])
).toEqual(true);
expect(isComponentAlreadyDeclared(BarComponent, [BarComponent], [AlphaModule])).toEqual(true);
});

it('should return false if the component is not declared', () => {
expect(isComponentAlreadyDeclaredInModules(BarComponent, [], [AlphaModule])).toEqual(false);
expect(isComponentAlreadyDeclared(BarComponent, [], [AlphaModule])).toEqual(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const reflectionCapabilities = new ReflectionCapabilities();
*
* Checks recursively if the component has already been declared in all import Module
*/
export const isComponentAlreadyDeclaredInModules = (
export const isComponentAlreadyDeclared = (
componentToFind: any,
moduleDeclarations: any[],
moduleImports: any[]
Expand All @@ -29,7 +29,7 @@ export const isComponentAlreadyDeclaredInModules = (
// Not an NgModule
return false;
}
return isComponentAlreadyDeclaredInModules(
return isComponentAlreadyDeclared(
componentToFind,
extractedNgModuleMetadata.declarations,
extractedNgModuleMetadata.imports
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { CommonModule } from '@angular/common';
import { Component, Directive, Injectable, InjectionToken, NgModule } from '@angular/core';
import { extractDeclarations, extractImports, extractProviders } from './PropertyExtractor';

const TEST_TOKEN = new InjectionToken('testToken');
const TestTokenProvider = { provide: TEST_TOKEN, useValue: 123 };
const TestService = Injectable()(class {});
const TestComponent1 = Component({})(class {});
const TestComponent2 = Component({})(class {});
const StandaloneTestComponent = Component({ standalone: true })(class {});
const TestDirective = Directive({})(class {});
const TestModuleWithDeclarations = NgModule({ declarations: [TestComponent1] })(class {});
const TestModuleWithImportsAndProviders = NgModule({
imports: [TestModuleWithDeclarations],
providers: [TestTokenProvider],
})(class {});

describe('PropertyExtractor', () => {
describe('extractImports', () => {
it('should return an array of imports', () => {
const imports = extractImports({ imports: [TestModuleWithImportsAndProviders] });
expect(imports).toEqual([CommonModule, TestModuleWithImportsAndProviders]);
});

it('should return an array of unique imports without providers', () => {
const imports = extractImports({
imports: [
TestModuleWithImportsAndProviders,
{ ngModule: TestModuleWithImportsAndProviders, providers: [] },
],
});
expect(imports).toEqual([CommonModule, TestModuleWithImportsAndProviders]);
});
});

describe('extractDeclarations', () => {
it('should return an array of declarations', () => {
const declarations = extractDeclarations({ declarations: [TestComponent1] }, TestComponent2);
expect(declarations).toEqual([TestComponent1, TestComponent2]);
});

it('should ignore pre-declared components', () => {
// TestComponent1 is declared as part of TestModuleWithDeclarations
// TestModuleWithDeclarations is imported by TestModuleWithImportsAndProviders
const declarations = extractDeclarations(
{
imports: [TestModuleWithImportsAndProviders],
declarations: [TestComponent2, StandaloneTestComponent, TestDirective],
},
TestComponent1
);
expect(declarations).toEqual([TestComponent2, StandaloneTestComponent, TestDirective]);
});

it('should ignore standalone components', () => {
const declarations = extractDeclarations(
{
imports: [TestModuleWithImportsAndProviders],
declarations: [TestComponent1, TestComponent2, TestDirective],
},
StandaloneTestComponent
);
expect(declarations).toEqual([TestComponent1, TestComponent2, TestDirective]);
});

it('should ignore non components/directives/pipes', () => {
const declarations = extractDeclarations(
{
imports: [TestModuleWithImportsAndProviders],
declarations: [TestComponent1, TestComponent2, StandaloneTestComponent],
},
TestService
);
expect(declarations).toEqual([TestComponent1, TestComponent2, StandaloneTestComponent]);
});
});

describe('extractProviders', () => {
it('should return an array of providers', () => {
const providers = extractProviders({
providers: [TestService],
});
expect(providers).toEqual([TestService]);
});

it('should return an array of providers extracted from ModuleWithProviders', () => {
const providers = extractProviders({
imports: [{ ngModule: TestModuleWithImportsAndProviders, providers: [TestService] }],
});
expect(providers).toEqual([TestService]);
});

it('should return an array of unique providers', () => {
const providers = extractProviders({
imports: [{ ngModule: TestModuleWithImportsAndProviders, providers: [TestService] }],
providers: [TestService, { provide: TEST_TOKEN, useValue: 123 }],
});
expect(providers).toEqual([
TestService,
{
provide: new InjectionToken('testToken'),
useValue: 123,
},
]);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { CommonModule } from '@angular/common';
sheriffMoose marked this conversation as resolved.
Show resolved Hide resolved
import { Provider } from '@angular/core';
import { NgModuleMetadata } from '../../types';
import { isDeclarable, isStandaloneComponent } from './NgComponentAnalyzer';
import { isComponentAlreadyDeclared } from './NgModulesAnalyzer';

const uniqueArray = (arr: any[]) => {
return arr.flat(Number.MAX_VALUE).filter((value, index, self) => self.indexOf(value) === index);
};

/**
* Extract Imports from NgModule
*
* CommonModule is always imported
*
* metadata.imports are flattened deeply and extracted into a new array
*
* - If ModuleWithProviders (e.g. forRoot() & forChild() ) is used, the ngModule is extracted without providers.
*
*/
export const extractImports = (metadata: NgModuleMetadata) => {
const imports = [CommonModule];

const modules = (metadata.imports || []).flat(Number.MAX_VALUE);
const withProviders = modules.filter((moduleDef) => !!moduleDef?.ngModule);
const withoutProviders = modules.filter((moduleDef) => !withProviders.includes(moduleDef));

return uniqueArray([
imports,
withoutProviders,
withProviders.map((moduleDef) => moduleDef.ngModule),
]);
};

/**
* Extract providers from NgModule
*
* - A new array is returned with:
* - metadata.providers
* - providers from each **ModuleWithProviders** (e.g. forRoot() & forChild() )
*
* - Use this in combination with extractImports to get all providers for a specific module
*
*/
export const extractProviders = (metadata: NgModuleMetadata): Provider[] => {
const providers = (metadata.providers || []) as Provider[];

const moduleProviders: Provider[] = (metadata.imports || [])
.flat(Number.MAX_VALUE)
.filter((moduleDef) => !!moduleDef?.ngModule)
.map((moduleDef) => moduleDef.providers || []);

return uniqueArray([].concat(moduleProviders, providers));
};

/**
* Extract declarations from NgModule
*
* - If a story component is provided, it will be added to the declarations array if:
* - It is a component or directive or pipe
* - It is not already declared
* - It is not a standalone component
*
*/
export const extractDeclarations = (metadata: NgModuleMetadata, storyComponent?: any) => {
const declarations = metadata.declarations || [];
if (storyComponent) {
const isStandalone = isStandaloneComponent(storyComponent);
const isDeclared = isComponentAlreadyDeclared(storyComponent, declarations, metadata.imports);

const requiresDeclaration = isDeclarable(storyComponent) && !isDeclared && !isStandalone;

if (requiresDeclaration) {
declarations.push(storyComponent);
}
}
return uniqueArray(declarations);
};
5 changes: 5 additions & 0 deletions code/nx.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
"devDependencies": "*"
}
},
"pluginsConfig": {
"@nrwl/js": {
"analyzeSourceFiles": false
}
},
"tasksRunnerOptions": {
"default": {
"runner": "@nrwl/nx-cloud",
Expand Down