diff --git a/core-libs/setup/ssr/error-handling/multi-error-handlers/propagating-to-server-error-handler.spec.ts b/core-libs/setup/ssr/error-handling/multi-error-handlers/propagating-to-server-error-handler.spec.ts index 97bf3d981fa..85f7db95356 100644 --- a/core-libs/setup/ssr/error-handling/multi-error-handlers/propagating-to-server-error-handler.spec.ts +++ b/core-libs/setup/ssr/error-handling/multi-error-handlers/propagating-to-server-error-handler.spec.ts @@ -1,39 +1,56 @@ import { TestBed } from '@angular/core/testing'; +import { FeatureConfigService } from '@spartacus/core'; import { PROPAGATE_ERROR_TO_SERVER } from '../error-response/propagate-error-to-server'; import { PropagatingToServerErrorHandler } from './propagating-to-server-error-handler'; describe('PropagatingToServerErrorHandler', () => { describe('default factories', () => { - let serverRespondingErrorHandler: PropagatingToServerErrorHandler; + let propagatingToServerErrorHandler: PropagatingToServerErrorHandler; + let featureConfigService: FeatureConfigService; let propagateErrorResponse: any; beforeEach(() => { TestBed.configureTestingModule({ providers: [ PropagatingToServerErrorHandler, + FeatureConfigService, { provide: PROPAGATE_ERROR_TO_SERVER, useValue: jest.fn(), }, ], }); - - serverRespondingErrorHandler = TestBed.inject( + propagatingToServerErrorHandler = TestBed.inject( PropagatingToServerErrorHandler ); propagateErrorResponse = TestBed.inject(PROPAGATE_ERROR_TO_SERVER); + featureConfigService = TestBed.inject(FeatureConfigService); }); afterEach(() => { jest.clearAllMocks(); }); - it('should propagate error', () => { + it('should propagate error when propagateErrorsToServer is enabled', () => { + jest + .spyOn(featureConfigService, 'isEnabled') + .mockImplementationOnce((val) => val === 'propagateErrorsToServer'); const error = new Error('test error'); - serverRespondingErrorHandler.handleError(error); + propagatingToServerErrorHandler.handleError(error); expect(propagateErrorResponse as jest.Mock).toHaveBeenCalledWith(error); }); + + it('should not propagate error when propagateErrorsToServer is disabled', () => { + jest + .spyOn(featureConfigService, 'isEnabled') + .mockImplementationOnce((val) => !(val === 'propagateErrorsToServer')); + const error = new Error('test error'); + + propagatingToServerErrorHandler.handleError(error); + + expect(propagateErrorResponse as jest.Mock).not.toHaveBeenCalled(); + }); }); }); diff --git a/core-libs/setup/ssr/error-handling/multi-error-handlers/propagating-to-server-error-handler.ts b/core-libs/setup/ssr/error-handling/multi-error-handlers/propagating-to-server-error-handler.ts index 26e12b056d2..68a65aa0e96 100644 --- a/core-libs/setup/ssr/error-handling/multi-error-handlers/propagating-to-server-error-handler.ts +++ b/core-libs/setup/ssr/error-handling/multi-error-handlers/propagating-to-server-error-handler.ts @@ -5,7 +5,7 @@ */ import { Injectable, inject } from '@angular/core'; -import { MultiErrorHandler } from '@spartacus/core'; +import { FeatureConfigService, MultiErrorHandler } from '@spartacus/core'; import { PROPAGATE_ERROR_TO_SERVER } from '../error-response/propagate-error-to-server'; /** @@ -28,8 +28,13 @@ import { PROPAGATE_ERROR_TO_SERVER } from '../error-response/propagate-error-to- }) export class PropagatingToServerErrorHandler implements MultiErrorHandler { protected propagateErrorToServer = inject(PROPAGATE_ERROR_TO_SERVER); + private featureConfigService: FeatureConfigService = + inject(FeatureConfigService); handleError(error: unknown): void { + if (!this.featureConfigService.isEnabled('propagateErrorsToServer')) { + return; + } this.propagateErrorToServer(error); } } diff --git a/core-libs/setup/ssr/optimized-engine/index.ts b/core-libs/setup/ssr/optimized-engine/index.ts index 18afefbeb11..cfa41066a42 100644 --- a/core-libs/setup/ssr/optimized-engine/index.ts +++ b/core-libs/setup/ssr/optimized-engine/index.ts @@ -6,6 +6,7 @@ export * from './optimized-ssr-engine'; export * from './rendering-cache'; +export * from './rendering-cache.model'; export * from './rendering-strategy-resolver'; export * from './rendering-strategy-resolver-options'; export { RequestContext, getRequestContext } from './request-context'; diff --git a/core-libs/setup/ssr/optimized-engine/optimized-ssr-engine.spec.ts b/core-libs/setup/ssr/optimized-engine/optimized-ssr-engine.spec.ts index ca026a3df9f..a8f8ddd5b6d 100644 --- a/core-libs/setup/ssr/optimized-engine/optimized-ssr-engine.spec.ts +++ b/core-libs/setup/ssr/optimized-engine/optimized-ssr-engine.spec.ts @@ -20,6 +20,7 @@ jest.mock('fs', () => ({ readFileSync: () => '', })); const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); +const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); class MockExpressServerLogger implements Partial { log(message: string, context: ExpressServerLoggerContext): void { @@ -39,7 +40,7 @@ class MockExpressServerLogger implements Partial { */ class TestEngineRunner { /** Accumulates html output for engine runs */ - renders: string[] = []; + renders: (string | Error)[] = []; /** Accumulates response parameters for engine runs */ responseParams: object[] = []; @@ -48,7 +49,11 @@ class TestEngineRunner { optimizedSsrEngine: OptimizedSsrEngine; engineInstance: NgExpressEngineInstance; - constructor(options: SsrOptimizationOptions, renderTime?: number) { + constructor( + options: SsrOptimizationOptions, + renderTime?: number, + params?: { withError?: boolean } + ) { // mocked engine instance that will render test output in 100 milliseconds const engineInstanceMock = ( filePath: string, @@ -56,17 +61,33 @@ class TestEngineRunner { callback: SsrCallbackFn ) => { setTimeout(() => { - callback(undefined, `${filePath}-${this.renderCount++}`); + const result = `${filePath}-${this.renderCount++}`; + + if (params?.withError) { + const err = new Error(result); + callback(err, undefined); + } else { + callback(undefined, result); + } }, renderTime ?? defaultRenderTime); }; - this.optimizedSsrEngine = new OptimizedSsrEngine( - engineInstanceMock, - options - ); + this.optimizedSsrEngine = new OptimizedSsrEngine(engineInstanceMock, { + shouldCacheRenderingResult: + defaultSsrOptimizationOptions.shouldCacheRenderingResult, + ...options, + }); this.engineInstance = this.optimizedSsrEngine.engineInstance; } + /** Create engine that results with error during render */ + static withError( + options: SsrOptimizationOptions, + renderTime = defaultRenderTime + ): TestEngineRunner { + return new TestEngineRunner(options, renderTime, { withError: true }); + } + /** Run request against the engine. The result will be stored in rendering property. */ request( url: string, @@ -102,8 +123,8 @@ class TestEngineRunner { }, }; - this.engineInstance(url, optionsMock, (_, html): void => { - this.renders.push(html ?? ''); + this.engineInstance(url, optionsMock, (error, html): void => { + this.renders.push(html ?? error ?? ''); this.responseParams.push(response); }); @@ -177,7 +198,9 @@ describe('OptimizedSsrEngine', () => { "reuseCurrentRendering": true, "debug": false, "renderingStrategyResolver": "() => ssr_optimization_options_1.RenderingStrategy.ALWAYS_SSR", - "logger": "DefaultExpressServerLogger" + "logger": "DefaultExpressServerLogger", + "shouldCacheRenderingResult": "({ options, entry }) => !(options.avoidCachingErrors === true && Boolean(entry.err))", + "avoidCachingErrors": false } } }", @@ -186,6 +209,34 @@ describe('OptimizedSsrEngine', () => { }); }); + describe('rendering', () => { + it('should return rendered output if no errors', fakeAsync(() => { + const originalUrl = 'a'; + const engineRunner = new TestEngineRunner({}).request('a'); + + tick(200); + expect(engineRunner.renders).toEqual(['a-0']); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Request is resolved with the SSR rendering result (${originalUrl})` + ) + ); + })); + + it('should return error if rendering fails', fakeAsync(() => { + const originalUrl = 'a'; + const engineRunner = TestEngineRunner.withError({}).request('a'); + + tick(200); + expect(engineRunner.renders).toEqual([new Error('a-0')]); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Request is resolved with the SSR rendering error (${originalUrl})` + ) + ); + })); + }); + describe('rendering cache', () => { it('should be initialized with default optimization options none of the custom options are provided', () => { const engineRunner = new TestEngineRunner({}); @@ -305,7 +356,6 @@ describe('OptimizedSsrEngine', () => { tick(200); expect(engineRunner.renders).toEqual(['a-0', 'a-1', 'a-2']); })); - it('should cache requests if enabled', fakeAsync(() => { const engineRunner = new TestEngineRunner({ cache: true, @@ -335,6 +385,140 @@ describe('OptimizedSsrEngine', () => { })); }); + describe('avoidCachingErrors option', () => { + describe('when using default shouldCacheRenderingResult', () => { + it('should not cache errors if `avoidCachingErrors` is set to true', fakeAsync(() => { + const engineRunner = TestEngineRunner.withError({ + cache: true, + avoidCachingErrors: true, + }).request('a'); + + tick(200); + engineRunner.request('a'); + tick(200); + engineRunner.request('a'); + tick(200); + expect(engineRunner.renders).toEqual([ + new Error('a-0'), + new Error('a-1'), + new Error('a-2'), + ]); + })); + + it('should cache errors if `avoidCachingErrors` is set to false', fakeAsync(() => { + const engineRunner = TestEngineRunner.withError({ + cache: true, + avoidCachingErrors: false, + }).request('a'); + + tick(200); + engineRunner.request('a'); + tick(200); + engineRunner.request('a'); + tick(200); + expect(engineRunner.renders).toEqual([ + new Error('a-0'), + new Error('a-0'), + new Error('a-0'), + ]); + })); + + it('should cache HTML if `avoidCachingErrors` is set to true', fakeAsync(() => { + const engineRunner = new TestEngineRunner({ + cache: true, + avoidCachingErrors: true, + }).request('a'); + + tick(200); + engineRunner.request('a'); + tick(200); + engineRunner.request('a'); + tick(200); + expect(engineRunner.renders).toEqual(['a-0', 'a-0', 'a-0']); + })); + + it('should cache HTML if `avoidCachingErrors` is set to false', fakeAsync(() => { + const engineRunner = new TestEngineRunner({ + cache: true, + avoidCachingErrors: true, + }).request('a'); + + tick(200); + engineRunner.request('a'); + tick(200); + engineRunner.request('a'); + tick(200); + expect(engineRunner.renders).toEqual(['a-0', 'a-0', 'a-0']); + })); + }); + }); + + describe('shouldCacheRenderingResult option', () => { + it('should not cache errors if `shouldCacheRenderingResult` returns false', fakeAsync(() => { + const engineRunner = TestEngineRunner.withError({ + cache: true, + shouldCacheRenderingResult: () => false, + }).request('a'); + + tick(200); + engineRunner.request('a'); + tick(200); + engineRunner.request('a'); + tick(200); + expect(engineRunner.renders).toEqual([ + new Error('a-0'), + new Error('a-1'), + new Error('a-2'), + ]); + })); + + it('should cache errors if `shouldCacheRenderingResult` returns true', fakeAsync(() => { + const engineRunner = TestEngineRunner.withError({ + cache: true, + shouldCacheRenderingResult: () => true, + }).request('a'); + + tick(200); + engineRunner.request('a'); + tick(200); + engineRunner.request('a'); + tick(200); + expect(engineRunner.renders).toEqual([ + new Error('a-0'), + new Error('a-0'), + new Error('a-0'), + ]); + })); + + it('should not cache HTML if `shouldCacheRenderingResult` returns false', fakeAsync(() => { + const engineRunner = new TestEngineRunner({ + cache: true, + shouldCacheRenderingResult: () => false, + }).request('a'); + + tick(200); + engineRunner.request('a'); + tick(200); + engineRunner.request('a'); + tick(200); + expect(engineRunner.renders).toEqual(['a-0', 'a-1', 'a-2']); + })); + + it('should cache HTML if `shouldCacheRenderingResult` returns true', fakeAsync(() => { + const engineRunner = new TestEngineRunner({ + cache: true, + shouldCacheRenderingResult: () => true, + }).request('a'); + + tick(200); + engineRunner.request('a'); + tick(200); + engineRunner.request('a'); + tick(200); + expect(engineRunner.renders).toEqual(['a-0', 'a-0', 'a-0']); + })); + }); + describe('concurrency option', () => { it('should limit concurrency and fallback to csr', fakeAsync(() => { const engineRunner = new TestEngineRunner({ @@ -1269,30 +1453,32 @@ describe('OptimizedSsrEngine', () => { logger: new MockExpressServerLogger() as ExpressServerLogger, }); expect(consoleLogSpy.mock.lastCall).toMatchInlineSnapshot(` - [ - "[spartacus] SSR optimization engine initialized", - { - "options": { - "cacheSize": 3000, - "concurrency": 10, - "debug": false, - "forcedSsrTimeout": 60000, - "logger": "MockExpressServerLogger", - "maxRenderTime": 300000, - "renderingStrategyResolver": "(request) => { - if (hasExcludedUrl(request, defaultAlwaysCsrOptions.excludedUrls)) { - return ssr_optimization_options_1.RenderingStrategy.ALWAYS_CSR; - } - return shouldFallbackToCsr(request, options) - ? ssr_optimization_options_1.RenderingStrategy.ALWAYS_CSR - : ssr_optimization_options_1.RenderingStrategy.DEFAULT; - }", - "reuseCurrentRendering": true, - "timeout": 3000, - }, - }, - ] - `); + [ + "[spartacus] SSR optimization engine initialized", + { + "options": { + "avoidCachingErrors": false, + "cacheSize": 3000, + "concurrency": 10, + "debug": false, + "forcedSsrTimeout": 60000, + "logger": "MockExpressServerLogger", + "maxRenderTime": 300000, + "renderingStrategyResolver": "(request) => { + if (hasExcludedUrl(request, defaultAlwaysCsrOptions.excludedUrls)) { + return ssr_optimization_options_1.RenderingStrategy.ALWAYS_CSR; + } + return shouldFallbackToCsr(request, options) + ? ssr_optimization_options_1.RenderingStrategy.ALWAYS_CSR + : ssr_optimization_options_1.RenderingStrategy.DEFAULT; + }", + "reuseCurrentRendering": true, + "shouldCacheRenderingResult": "({ options, entry }) => !(options.avoidCachingErrors === true && Boolean(entry.err))", + "timeout": 3000, + }, + }, + ] + `); }); }); }); diff --git a/core-libs/setup/ssr/optimized-engine/rendering-cache.model.ts b/core-libs/setup/ssr/optimized-engine/rendering-cache.model.ts new file mode 100644 index 00000000000..d95a53c8f0b --- /dev/null +++ b/core-libs/setup/ssr/optimized-engine/rendering-cache.model.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Represents a rendering entry in the rendering cache. + */ +export interface RenderingEntry { + html?: any; + err?: any; + time?: number; + rendering?: boolean; +} diff --git a/core-libs/setup/ssr/optimized-engine/rendering-cache.spec.ts b/core-libs/setup/ssr/optimized-engine/rendering-cache.spec.ts index 5cc6e8794e0..cd23fe9b914 100644 --- a/core-libs/setup/ssr/optimized-engine/rendering-cache.spec.ts +++ b/core-libs/setup/ssr/optimized-engine/rendering-cache.spec.ts @@ -1,12 +1,22 @@ /// import { RenderingCache } from './rendering-cache'; +import { + SsrOptimizationOptions, + defaultSsrOptimizationOptions, +} from './ssr-optimization-options'; + +const options: SsrOptimizationOptions = { + shouldCacheRenderingResult: + defaultSsrOptimizationOptions.shouldCacheRenderingResult, + avoidCachingErrors: defaultSsrOptimizationOptions.avoidCachingErrors, +}; describe('RenderingCache', () => { let renderingCache: RenderingCache; beforeEach(() => { - renderingCache = new RenderingCache({}); + renderingCache = new RenderingCache(options); }); it('should create rendering cache instance', () => { @@ -77,13 +87,13 @@ describe('RenderingCache with ttl', () => { let renderingCache: RenderingCache; beforeEach(() => { - renderingCache = new RenderingCache({ ttl: 100 }); + renderingCache = new RenderingCache({ ...options, ttl: 100 }); }); describe('get', () => { it('should return timestamp', () => { renderingCache.store('test', null, 'testHtml'); - expect(renderingCache.get('test').time).toBeTruthy(); + expect(renderingCache.get('test')?.time).toBeTruthy(); }); }); @@ -118,7 +128,7 @@ describe('RenderingCache with cacheSize', () => { let renderingCache: RenderingCache; beforeEach(() => { - renderingCache = new RenderingCache({ cacheSize: 2 }); + renderingCache = new RenderingCache({ ...options, cacheSize: 2 }); }); describe('get', () => { @@ -151,4 +161,67 @@ describe('RenderingCache with cacheSize', () => { expect(renderingCache.get('c')).toBeTruthy(); }); }); + + describe('RenderingCache and shouldCacheRenderingResult', () => { + let renderingCache: RenderingCache; + + describe('if default shouldCacheRenderingResult', () => { + it('should cache HTML if avoidCachingErrors is false', () => { + renderingCache = new RenderingCache({ + ...options, + avoidCachingErrors: false, + }); + renderingCache.store('a', undefined, 'a'); + expect(renderingCache.get('a')).toEqual({ html: 'a', err: undefined }); + }); + + it('should cache HTML if avoidCachingErrors is true', () => { + renderingCache = new RenderingCache({ + ...options, + avoidCachingErrors: false, + }); + renderingCache.store('a', undefined, 'a'); + expect(renderingCache.get('a')).toEqual({ html: 'a', err: undefined }); + }); + + it('should cache errors if avoidCachingErrors is false', () => { + renderingCache = new RenderingCache({ + ...options, + avoidCachingErrors: false, + }); + renderingCache.store('a', new Error('err'), 'a'); + expect(renderingCache.get('a')).toEqual({ + html: 'a', + err: new Error('err'), + }); + }); + + it('should not cache errors if avoidCachingErrors is true', () => { + renderingCache = new RenderingCache({ + ...options, + avoidCachingErrors: true, + }); + renderingCache.store('a', new Error('err'), 'a'); + expect(renderingCache.get('a')).toBeFalsy(); + }); + }); + + describe('if shouldCacheRenderingResult is not defined', () => { + beforeEach(() => { + renderingCache = new RenderingCache({ + ...options, + shouldCacheRenderingResult: undefined, + }); + }); + it('should not cache a html', () => { + renderingCache.store('a', undefined, 'a'); + expect(renderingCache.get('a')).toBeFalsy(); + }); + + it('should not cache an error', () => { + renderingCache.store('a', new Error('err'), 'a'); + expect(renderingCache.get('a')).toBeFalsy(); + }); + }); + }); }); diff --git a/core-libs/setup/ssr/optimized-engine/rendering-cache.ts b/core-libs/setup/ssr/optimized-engine/rendering-cache.ts index 88833775be2..f7700687fb7 100644 --- a/core-libs/setup/ssr/optimized-engine/rendering-cache.ts +++ b/core-libs/setup/ssr/optimized-engine/rendering-cache.ts @@ -4,15 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { RenderingEntry } from './rendering-cache.model'; import { SsrOptimizationOptions } from './ssr-optimization-options'; -export interface RenderingEntry { - html?: any; - err?: any; - time?: number; - rendering?: boolean; -} - export class RenderingCache { protected renders = new Map(); @@ -37,7 +31,15 @@ export class RenderingCache { this.renders.delete(this.renders.keys().next().value); } } - this.renders.set(key, entry); + // cache only if shouldCacheRenderingResult return true + if ( + this.options?.shouldCacheRenderingResult?.({ + options: this.options, + entry, + }) + ) { + this.renders.set(key, entry); + } } get(key: string): RenderingEntry | undefined { diff --git a/core-libs/setup/ssr/optimized-engine/ssr-optimization-options.ts b/core-libs/setup/ssr/optimized-engine/ssr-optimization-options.ts index c4da1a62c6c..98b97c9952d 100644 --- a/core-libs/setup/ssr/optimized-engine/ssr-optimization-options.ts +++ b/core-libs/setup/ssr/optimized-engine/ssr-optimization-options.ts @@ -6,6 +6,7 @@ import { Request } from 'express'; import { DefaultExpressServerLogger, ExpressServerLogger } from '../logger'; +import { RenderingEntry } from './rendering-cache.model'; import { defaultRenderingStrategyResolver } from './rendering-strategy-resolver'; import { defaultRenderingStrategyResolverOptions } from './rendering-strategy-resolver-options'; @@ -130,6 +131,34 @@ export interface SsrOptimizationOptions { * By default, the DefaultExpressServerLogger is used. */ logger?: ExpressServerLogger; + + /** + * When caching is enabled, this function tell whether the given rendering result + * (html or error) should be cached. + * + * By default, all html rendering results are cached. By default, also all errors are cached + * unless the separate option `avoidCachingErrors` is enabled. + */ + shouldCacheRenderingResult?: ({ + options, + entry, + }: { + options: SsrOptimizationOptions; + entry: Pick; + }) => boolean; + + /** + * Determines if rendering errors should be skipped from caching. + * + * NOTE: It's a temporary feature toggle, to be removed in the future. + * + * It's recommended to set to `true` (i.e. errors are skipped from caching), + * which will become the default behavior, when this feature toggle is removed. + * + * It only affects the default `shouldCacheRenderingResult`. + * Custom implementations of `shouldCacheRenderingResult` may ignore this setting. + */ + avoidCachingErrors?: boolean; } export enum RenderingStrategy { @@ -150,4 +179,7 @@ export const defaultSsrOptimizationOptions: SsrOptimizationOptions = { defaultRenderingStrategyResolverOptions ), logger: new DefaultExpressServerLogger(), + shouldCacheRenderingResult: ({ options, entry }) => + !(options.avoidCachingErrors === true && Boolean(entry.err)), + avoidCachingErrors: false, }; diff --git a/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts b/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts index 7be66635ecb..9494b20b7f7 100644 --- a/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts +++ b/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts @@ -84,6 +84,16 @@ export interface FeatureTogglesInterface { */ productConfiguratorAttributeTypesV2?: boolean; + /** + * In a server environment (SSR or Prerendering) it propagates all errors caught in Angular app + * (in the Angular's `ErrorHandler` class) to the server layer. + * + * In SSR, such a propagation allows the server layer (e.g. ExpressJS) for handling those errors, + * e.g. sending a proper Error Page in response to the client, + * instead of a rendered HTML that is possibly malformed due to the occurred error. + */ + propagateErrorsToServer?: boolean; + /** * In SSR, the following errors will be printed to logs (and additionally can also * be forwarded to ExpressJS if only the setting @@ -392,6 +402,7 @@ export const defaultFeatureToggles: Required = { pdfInvoicesSortByInvoiceDate: false, storeFrontLibCardParagraphTruncated: false, productConfiguratorAttributeTypesV2: false, + propagateErrorsToServer: false, ssrStrictErrorHandlingForHttpAndNgrx: false, a11yRequiredAsterisks: false, a11yQuantityOrderTabbing: false, diff --git a/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts index 93cf1fc6e7a..cbe0bb09e9f 100644 --- a/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts +++ b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts @@ -280,6 +280,7 @@ if (environment.estimatedDeliveryDate) { pdfInvoicesSortByInvoiceDate: false, storeFrontLibCardParagraphTruncated: true, productConfiguratorAttributeTypesV2: true, + propagateErrorsToServer: true, ssrStrictErrorHandlingForHttpAndNgrx: true, a11yRequiredAsterisks: true, a11yQuantityOrderTabbing: true,