Skip to content

Commit

Permalink
perf: Improve rendering performance for large configuration models (#…
Browse files Browse the repository at this point in the history
…19099)

Closes CXSPA-7477
  • Loading branch information
Uli-Tiger authored Aug 6, 2024
1 parent 806fd8a commit e24eee4
Show file tree
Hide file tree
Showing 59 changed files with 2,097 additions and 479 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { Type, ViewContainerRef } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { FeatureConfigService, LoggerService } from '@spartacus/core';
import { ConfiguratorTestUtils } from '../../../testing/configurator-test-utils';
import { ConfiguratorAttributeCompositionConfig } from './configurator-attribute-composition.config';
import { ConfiguratorAttributeCompositionDirective } from './configurator-attribute-composition.directive';
import createSpy = jasmine.createSpy;

class TestComponent {}

class MockViewContainerRef {
clear = createSpy('vcr.clear');
createComponent = createSpy('vcr.createComponent');
}

let productConfiguratorDeltaRenderingEnabled = false;
class MockFeatureConfigService {
isEnabled(name: string): boolean {
if (name === 'productConfiguratorDeltaRendering') {
return productConfiguratorDeltaRenderingEnabled;
}
return false;
}
}

describe('ConfiguratorAttributeCompositionDirective', () => {
let classUnderTest: ConfiguratorAttributeCompositionDirective;
let viewContainerRef: ViewContainerRef;
let loggerService: LoggerService;

function init() {
classUnderTest = TestBed.inject(
ConfiguratorAttributeCompositionDirective as Type<ConfiguratorAttributeCompositionDirective>
);
viewContainerRef = TestBed.inject(
ViewContainerRef as Type<ViewContainerRef>
);
loggerService = TestBed.inject(LoggerService as Type<LoggerService>);
spyOn(loggerService, 'warn').and.callThrough();

classUnderTest['context'] = ConfiguratorTestUtils.getAttributeContext();
productConfiguratorDeltaRenderingEnabled = false;
}

beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ConfiguratorAttributeCompositionDirective,
{
provide: ConfiguratorAttributeCompositionConfig,
useValue: {
productConfigurator: {
assignment: { testComponent: TestComponent },
},
},
},
{
provide: ViewContainerRef,
useClass: MockViewContainerRef,
},
{
provide: FeatureConfigService,
useClass: MockFeatureConfigService,
},
],
});
});

it('should create', () => {
init();
expect(classUnderTest).toBeDefined();
});

it('should handle missing assignment config', () => {
TestBed.overrideProvider(ConfiguratorAttributeCompositionConfig, {
useValue: {
productConfigurator: { assignment: undefined },
},
});
init();
expect(classUnderTest['attrComponentAssignment']).toBeDefined();
});

describe('ngOnInit', () => {
beforeEach(() => {
init();
});

it('should render view if performance feature toggle is off', () => {
classUnderTest.ngOnInit();
expectComponentRendered(1);
});

it('should log if performance feature toggle is off but no component found', () => {
classUnderTest['context'].componentKey = 'not.existing';
classUnderTest.ngOnInit();
expectComponentNotRendered(true);
});

it('should do nothing if performance feature toggle is on', () => {
productConfiguratorDeltaRenderingEnabled = true;
classUnderTest.ngOnInit();
expectComponentNotRendered(false);
});
});

describe('ngOnChanges', () => {
beforeEach(() => {
init();
});

it('should render view if performance feature toggle is on', () => {
productConfiguratorDeltaRenderingEnabled = true;
classUnderTest.ngOnChanges();
expectComponentRendered(1);
});

it('should render the attribute only once if it did not change', () => {
productConfiguratorDeltaRenderingEnabled = true;
classUnderTest.ngOnChanges();
// re-create another context with the same attribute
classUnderTest['context'] = ConfiguratorTestUtils.getAttributeContext();
classUnderTest.ngOnChanges();
expectComponentRendered(1);
});

it('should re-render the attribute if it changed', () => {
productConfiguratorDeltaRenderingEnabled = true;
classUnderTest.ngOnChanges();
// re-create another context with the different attribute
classUnderTest['context'] = ConfiguratorTestUtils.getAttributeContext();
classUnderTest['context'].attribute.selectedSingleValue = 'changed';
classUnderTest.ngOnChanges();
expectComponentRendered(2);
});

it('should re-render the attribute if group changes', () => {
productConfiguratorDeltaRenderingEnabled = true;
classUnderTest.ngOnChanges();
// re-create another context with the different attribute
classUnderTest['context'] = ConfiguratorTestUtils.getAttributeContext();
classUnderTest['context'].group.id = 'changed';
classUnderTest.ngOnChanges();
expectComponentRendered(2);
});

it('should log if performance feature toggle is on but no component found', () => {
productConfiguratorDeltaRenderingEnabled = true;
classUnderTest['context'].componentKey = 'not.existing';
classUnderTest.ngOnChanges();
expectComponentNotRendered(true);
});

it('should do nothing if performance feature toggle is off', () => {
productConfiguratorDeltaRenderingEnabled = false;
classUnderTest.ngOnChanges();
expectComponentNotRendered(false);
});
});

function expectComponentRendered(times: number) {
expect(viewContainerRef.clear).toHaveBeenCalledTimes(times);
expect(viewContainerRef.createComponent).toHaveBeenCalledTimes(times);
expect(loggerService.warn).not.toHaveBeenCalled();
}

function expectComponentNotRendered(expectLog: boolean) {
expect(viewContainerRef.clear).not.toHaveBeenCalled();
expect(viewContainerRef.createComponent).not.toHaveBeenCalled();
if (expectLog) {
expect(loggerService.warn).toHaveBeenCalled();
} else {
expect(loggerService.warn).not.toHaveBeenCalled();
}
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,40 +10,84 @@ import {
Injector,
Input,
isDevMode,
OnChanges,
OnInit,
ViewContainerRef,
} from '@angular/core';
import { LoggerService } from '@spartacus/core';
import { ConfiguratorAttributeCompositionConfig } from './configurator-attribute-composition.config';
import {
FeatureConfigService,
LoggerService,
ObjectComparisonUtils,
} from '@spartacus/core';
import {
AttributeComponentAssignment,
ConfiguratorAttributeCompositionConfig,
} from './configurator-attribute-composition.config';
import { ConfiguratorAttributeCompositionContext } from './configurator-attribute-composition.model';
import { Configurator } from '../../../core/model/configurator.model';

@Directive({
selector: '[cxConfiguratorAttributeComponent]',
})
export class ConfiguratorAttributeCompositionDirective implements OnInit {
export class ConfiguratorAttributeCompositionDirective
implements OnInit, OnChanges
{
@Input('cxConfiguratorAttributeComponent')
context: ConfiguratorAttributeCompositionContext;

protected lastRenderedAttribute: Configurator.Attribute;
protected lastRenderedGroupId: string;

protected logger = inject(LoggerService);
private featureConfigService = inject(FeatureConfigService);

protected readonly attrComponentAssignment: AttributeComponentAssignment =
this.configuratorAttributeCompositionConfig.productConfigurator
?.assignment ?? [];

constructor(
protected vcr: ViewContainerRef,
protected configuratorAttributeCompositionConfig: ConfiguratorAttributeCompositionConfig
) {}

ngOnInit(): void {
const componentKey = this.context.componentKey;
if (
!this.featureConfigService.isEnabled('productConfiguratorDeltaRendering')
) {
const key = this.context.componentKey;
this.renderComponent(this.attrComponentAssignment[key], key);
}
}

const composition =
this.configuratorAttributeCompositionConfig.productConfigurator
?.assignment;
if (composition) {
this.renderComponent(composition[componentKey], componentKey);
/*
* Each time we update the configuration a completely new configuration state is emitted, including new attribute objects,
* regardless of whether an attribute actually changed or not. Hence, we compare the last rendered attribute with the current state
* and only destroy and re-create the attribute component, if there are actual changes to its data. This improves performance significantly.
*/
ngOnChanges(): void {
if (
this.featureConfigService.isEnabled('productConfiguratorDeltaRendering')
) {
const attributeChanged = !ObjectComparisonUtils.deepEqualObjects(
this.lastRenderedAttribute,
this.context.attribute
);
const groupChanged = this.lastRenderedGroupId !== this.context.group.id;
// attribute can occur with same content twice in different groups
// for example this happens for conflicts. An attribute is rendered differently (link from/to conflict) based on
// if it is part of conflict group or of ordinary group
if (attributeChanged || groupChanged) {
const key = this.context.componentKey;
this.renderComponent(this.attrComponentAssignment[key], key);
}
}
}

protected renderComponent(component: any, componentKey: string) {
if (component) {
this.lastRenderedAttribute = this.context.attribute;
this.lastRenderedGroupId = this.context.group.id;
this.vcr.clear();
this.vcr.createComponent(component, {
injector: this.getComponentInjector(),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ export class ConfiguratorAttributeCompositionContext {
language: string;
expMode: boolean;
isNavigationToGroupEnabled?: boolean;
isPricingAsync?: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/

export * from './composition/index';
export * from './price-change/index';
export * from './footer/index';
export * from './header/index';
export * from './product-card/index';
Expand All @@ -22,4 +24,3 @@ export * from './types/read-only/index';
export * from './types/single-selection-bundle-dropdown/index';
export * from './types/single-selection-bundle/index';
export * from './types/single-selection-image/index';
export * from './composition/index';
Loading

0 comments on commit e24eee4

Please sign in to comment.