diff --git a/CHANGELOG.md b/CHANGELOG.md index ca1819127c8..359df535a16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ All notable changes for each version of this project will be documented in this - `IgxDialogComponent` - Fixed `open()` leaving dialogs hidden when called through an `OnPush` child component from a parent or an asynchronous callback, in both zoneless and ZoneJS applications. +- `IgxGridComponent`, `IgxTreeGridComponent`, `IgxHierarchicalGridComponent`, `IgxPivotGridComponent` + - Restored the style recalculation performance of 21.2.x in Chromium-based browsers. The grid, Excel-style filtering and query builder themes no longer use multi-selector `:where()` lists as rule subjects, which Chromium matched against every element on each style recalculation. The styles are otherwise unchanged. + - Filtering, grouping, editing and toggling `isLoading` no longer add and remove the stylesheets of the badge, chip, checkbox, input group, date picker and circular progress bar components, which forced a restyle of the whole page each time. The grid now keeps these stylesheets for the application's lifetime. As a result, the first grid in an application uses up one auto-generated `id` of the badge, chip, checkbox, date picker and circular progress bar (including `igx-circular-gradient-N`), so instances created after it get an `id` one higher than before. Later grids do not shift the ids further. - `IgxGridExcelStyleFilteringComponent` - Menu spacing now follows `--ig-size` when `minHeight` or `maxHeight` is set, including when rendered in an external outlet or outside the grid. Previously it could fall back to large spacing (#17589). - **Forms** diff --git a/projects/igniteui-angular/core/src/core/styles/spec/_mixins.spec.scss b/projects/igniteui-angular/core/src/core/styles/spec/_mixins.spec.scss index c8e626e7d03..d71c0a9b41e 100644 --- a/projects/igniteui-angular/core/src/core/styles/spec/_mixins.spec.scss +++ b/projects/igniteui-angular/core/src/core/styles/spec/_mixins.spec.scss @@ -32,6 +32,28 @@ } } +// stylelint-disable max-nesting-depth +@include describe('Scope') { + @include it('should wrap each selector of a list in its own :where()') { + @include assert() { + @include output() { + @include scoping.scope(test, '.a, b, .c .d', ()) using ($schema) { + color: red; + } + } + + @include expect() { + @layer ig.test { + :where(.a), :where(b), :where(.c .d) { + color: red; + } + } + } + } + } +} +// stylelint-enable max-nesting-depth + @include describe('Generate color class') { @include it('should concatenate the name, variant, prefix, and suffix in the correct order.') { $name: primary; diff --git a/projects/igniteui-angular/core/src/core/styles/themes/_scoping.scss b/projects/igniteui-angular/core/src/core/styles/themes/_scoping.scss index 36bd963d132..b5261ee1def 100644 --- a/projects/igniteui-angular/core/src/core/styles/themes/_scoping.scss +++ b/projects/igniteui-angular/core/src/core/styles/themes/_scoping.scss @@ -1,4 +1,6 @@ +@use 'sass:list'; @use 'sass:map'; +@use 'sass:selector'; @use 'igniteui-theming/sass/bem' as *; @use 'igniteui-theming/sass/themes' as *; @forward 'igniteui-theming/sass/themes'; @@ -93,8 +95,10 @@ } /// Wraps the passed content in a named cascade layer and scopes it to the given -/// selector through `:where()`, keeping specificity at zero. The active schema is -/// yielded to the content block so callers can resolve themes against it. +/// selector through `:where()`, keeping specificity at zero. Each selector of a list +/// gets its own `:where()`, as Chromium can't bucket multi-argument `:where()` subjects +/// and would match them against every element. The active schema is yielded to the +/// content block so callers can resolve themes against it. /// @access private /// @param {String} $layer - The cascade layer name (the `ig.` prefix is added by `layer`). /// @param {String} $selector - The selector the emitted styles are scoped to. @@ -102,10 +106,16 @@ /// @content The declarations to emit; receives `$schema` as its single argument, /// so include it with `using ($schema)`. /// @requires {mixin} layer -/// @output The content wrapped in `@layer ig.#{$layer}` inside a `:where(#{$selector})` rule. +/// @output The content wrapped in `@layer ig.#{$layer}` inside a `:where(a), :where(b)` rule. @mixin scope($layer, $selector, $schema) { + $_scoped: (); + + @each $complex in selector.parse($selector) { + $_scoped: list.append($_scoped, ':where(#{$complex})', $separator: comma); + } + @include layer($layer) { - :where(#{$selector}) { + #{$_scoped} { @content($schema); } } diff --git a/projects/igniteui-angular/core/src/services/styles/styles-registrar.service.spec.ts b/projects/igniteui-angular/core/src/services/styles/styles-registrar.service.spec.ts new file mode 100644 index 00000000000..f0ce598df72 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/styles/styles-registrar.service.spec.ts @@ -0,0 +1,141 @@ +import { Component, ElementRef, inject, OnDestroy, ViewEncapsulation } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { PlatformUtil } from '../../core/utils'; +import { IgxStylesRegistrar } from './styles-registrar.service'; + +let instances: ElementRef[] = []; +let destroyed = 0; + +@Component({ + selector: 'igx-retain-test', + template: '', + styles: ['.igx-retain-test { color: red; }'], + encapsulation: ViewEncapsulation.None +}) +class RetainTestComponent implements OnDestroy { + constructor() { + instances.push(inject(ElementRef)); + } + + public ngOnDestroy(): void { + destroyed++; + } +} + +@Component({ + selector: 'igx-plain-test', + template: '', + styles: ['.igx-plain-test { color: blue; }'], + encapsulation: ViewEncapsulation.None +}) +class PlainTestComponent { } + +// Compiled styles are reformatted, so match on the class name. +const hasStyle = (className: string) => + Array.from(document.head.querySelectorAll('style')).some(style => style.textContent?.includes(className)); + +describe('IgxStylesRegistrar', () => { + describe('register', () => { + let initialSheets: CSSStyleSheet[]; + + beforeEach(() => { + initialSheets = [...document.adoptedStyleSheets]; + }); + + afterEach(() => { + document.adoptedStyleSheets = initialSheets; + document.head.querySelectorAll('style[data-registrar-test]').forEach(style => style.remove()); + }); + + it('should adopt a stylesheet once per id', () => { + const registrar = TestBed.inject(IgxStylesRegistrar); + const id = Symbol('once'); + + registrar.register(id, '.igx-register-once { color: red; }'); + registrar.register(id, '.igx-register-once { color: red; }'); + + const added = document.adoptedStyleSheets.slice(initialSheets.length); + expect(added.length).toBe(1); + expect(added[0].cssRules[0].cssText).toContain('.igx-register-once'); + }); + + it('should adopt a separate stylesheet for each id', () => { + const registrar = TestBed.inject(IgxStylesRegistrar); + + registrar.register(Symbol('first'), '.igx-register-first { color: red; }'); + registrar.register(Symbol('second'), '.igx-register-second { color: red; }'); + + expect(document.adoptedStyleSheets.length - initialSheets.length).toBe(2); + }); + + it('should register an id once per document across injectors', () => { + const id = Symbol('per-document'); + + TestBed.inject(IgxStylesRegistrar).register(id, '.igx-register-doc { color: red; }'); + TestBed.resetTestingModule(); + TestBed.inject(IgxStylesRegistrar).register(id, '.igx-register-doc { color: red; }'); + + expect(document.adoptedStyleSheets.length - initialSheets.length).toBe(1); + }); + + it('should append a style element outside the browser', () => { + TestBed.configureTestingModule({ + providers: [{ provide: PlatformUtil, useValue: { isBrowser: false } }] + }); + + TestBed.inject(IgxStylesRegistrar).register(Symbol('server'), '.igx-register-server { color: red; }'); + + const style = Array.from(document.head.querySelectorAll('style')) + .find(s => s.textContent?.includes('.igx-register-server')); + style?.setAttribute('data-registrar-test', ''); + expect(style).toBeDefined(); + expect(document.adoptedStyleSheets.length).toBe(initialSheets.length); + }); + }); + + describe('retain', () => { + // Angular tears down the previous test's app after afterEach hooks run. + beforeEach(() => { + instances = []; + destroyed = 0; + }); + + it('should keep a component\'s styles after its last instance is destroyed', () => { + TestBed.inject(IgxStylesRegistrar).retain(RetainTestComponent); + + const fixture = TestBed.createComponent(RetainTestComponent); + fixture.destroy(); + + expect(hasStyle('igx-retain-test')).toBe(true); + }); + + it('should not keep the styles of components that are not retained', () => { + const fixture = TestBed.createComponent(PlainTestComponent); + expect(hasStyle('igx-plain-test')).toBe(true); + + fixture.destroy(); + + expect(hasStyle('igx-plain-test')).toBe(false); + }); + + it('should create a single detached instance per component', () => { + const registrar = TestBed.inject(IgxStylesRegistrar); + + registrar.retain(RetainTestComponent); + registrar.retain(RetainTestComponent, RetainTestComponent); + + expect(instances.length).toBe(1); + expect(instances[0].nativeElement.isConnected).toBe(false); + }); + + it('should destroy the retained instances when the application is destroyed', () => { + TestBed.inject(IgxStylesRegistrar).retain(RetainTestComponent); + expect(destroyed).toBe(0); + + TestBed.resetTestingModule(); + + expect(destroyed).toBe(1); + expect(hasStyle('igx-retain-test')).toBe(false); + }); + }); +}); diff --git a/projects/igniteui-angular/core/src/services/styles/styles-registrar.service.ts b/projects/igniteui-angular/core/src/services/styles/styles-registrar.service.ts index ce8adab84e2..fa7e45cb37a 100644 --- a/projects/igniteui-angular/core/src/services/styles/styles-registrar.service.ts +++ b/projects/igniteui-angular/core/src/services/styles/styles-registrar.service.ts @@ -1,4 +1,4 @@ -import { inject, Injectable } from '@angular/core'; +import { ApplicationRef, ComponentRef, createComponent, EnvironmentInjector, inject, Injectable, Type } from '@angular/core'; import { DOCUMENT } from '@angular/common'; import { PlatformUtil } from '../../core/utils'; @@ -27,10 +27,14 @@ import { PlatformUtil } from '../../core/utils'; */ const _registries = new WeakMap>(); +/** Keyed by application, as Angular shares component styles per application. */ +const _retained = new WeakMap, ComponentRef>>(); + @Injectable({ providedIn: 'root' }) export class IgxStylesRegistrar { private readonly _document = inject(DOCUMENT); private readonly _platform = inject(PlatformUtil); + private readonly _injector = inject(EnvironmentInjector); /** * Registers a stylesheet identified by `id`. Subsequent calls with the same @@ -62,4 +66,36 @@ export class IgxStylesRegistrar { this._document.head?.appendChild(style); } } + + /** + * Keeps the components' styles in the document for the application's lifetime, through a + * detached instance of each. Otherwise Angular removes them with the last instance and + * re-inserts them with the next one, restyling the page each time. + * + * @param components - The components whose styles to retain. + * + * @example + * ```typescript + * inject(IgxStylesRegistrar).retain(IgxBadgeComponent, IgxChipComponent); + * ``` + */ + public retain(...components: Type[]): void { + const appRef = this._injector.get(ApplicationRef); + let refs = _retained.get(appRef); + + if (!refs) { + refs = new Map(); + _retained.set(appRef, refs); + appRef.onDestroy(() => { + _retained.get(appRef)?.forEach(ref => ref.destroy()); + _retained.delete(appRef); + }); + } + + for (const type of components) { + if (!refs.has(type)) { + refs.set(type, createComponent(type, { environmentInjector: this._injector })); + } + } + } } diff --git a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts index 5792702e28c..2977216fcaa 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts @@ -118,6 +118,12 @@ import { import { IgxGridGroupByAreaComponent } from './grouping/grid-group-by-area.component'; import { IgxPaginatorToken, type IgxPaginatorComponent } from 'igniteui-angular/paginator'; import { IgxSnackbarComponent } from 'igniteui-angular/snackbar'; +import { IgxBadgeComponent } from 'igniteui-angular/badge'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; +import { IgxChipComponent } from 'igniteui-angular/chips'; +import { IgxDatePickerComponent } from 'igniteui-angular/date-picker'; +import { IgxInputGroupComponent } from 'igniteui-angular/input-group'; +import { IgxCircularProgressBarComponent } from 'igniteui-angular/progressbar'; import { CellType, CharSeparatedValueData, DropPosition, FilterMode, getUUID, GridCellMergeMode, GridKeydownTargetType, GridPagingMode, GridSelectionMode, GridServiceType, GridSummaryPosition, GridType, GridValidationTrigger, IActiveNode, IActiveNodeChangeEventArgs, ICellPosition, IClipboardOptions, IColumnMovingEndEventArgs, IColumnMovingEventArgs, IColumnMovingStartEventArgs, IColumnResizeEventArgs, IColumnSelectionEventArgs, IColumnVisibilityChangedEventArgs, IColumnVisibilityChangingEventArgs, IFilteringEventArgs, IGridCellEventArgs, IGridClipboardEvent, IGridContextMenuEventArgs, IGridEditDoneEventArgs, IGridEditEventArgs, IGridFormGroupCreatedEventArgs, IGridKeydownEventArgs, IGridRowEventArgs, IGridScrollEventArgs, IGridToolbarExportEventArgs, IGridValidationStatusEventArgs, IGX_GRID_SERVICE_BASE, IgxAdvancedFilteringDialogComponent, IgxCell, IgxColumnComponent, IgxColumnGroupComponent, IgxColumnResizingService, IgxDragIndicatorIconDirective, IgxEditRow, IgxExcelStyleHeaderIconDirective, IgxExcelStyleLoadingValuesTemplateDirective, IgxFilteringService, IgxGridBodyDirective, IgxGridCellComponent, IgxGridColumnResizerComponent, IgxGridEmptyTemplateContext, IgxGridEmptyTemplateDirective, IgxGridExcelStyleFilteringComponent, IgxGridFilteringCellComponent, IgxGridFilteringRowComponent, IgxGridHeaderComponent, IgxGridHeaderGroupComponent, IgxGridHeaderRowComponent, IgxGridHeaderTemplateContext, IgxGridLoadingTemplateDirective, IgxGridNavigationService, IgxGridPinningActionsComponent, IgxGridRowDragGhostContext, IgxGridRowEditActionsTemplateContext, IgxGridRowEditTemplateContext, IgxGridRowEditTextTemplateContext, IgxGridRowTemplateContext, IgxGridSelectionService, IgxGridSummaryService, IgxGridTemplateContext, IgxGridToolbarComponent, IgxGridValidationService, IgxHeaderCollapsedIndicatorDirective, IgxHeaderExpandedIndicatorDirective, IgxHeadSelectorDirective, IgxHeadSelectorTemplateContext, IgxRowAddTextDirective, IgxRowCollapsedIndicatorDirective, IgxRowDirective, IgxRowDragGhostDirective, IgxRowEditActionsDirective, IgxRowEditTabStopDirective, IgxRowEditTemplateDirective, IgxRowEditTextDirective, IgxRowExpandedIndicatorDirective, IgxRowSelectorDirective, IgxRowSelectorTemplateContext, IgxSortAscendingHeaderIconDirective, IgxSortDescendingHeaderIconDirective, IgxSortHeaderIconDirective, IgxSummaryRowComponent, IgxToolbarToken, IPinColumnCancellableEventArgs, IPinColumnEventArgs, IPinningConfig, IPinRowEventArgs, IRowDataCancelableEventArgs, IRowDataEventArgs, IRowDragEndEventArgs, IRowDragStartEventArgs, IRowSelectionEventArgs, IRowToggleEventArgs, ISearchInfo, ISizeInfo, ISortingEventArgs, RowEditPositionStrategy, RowPinningPosition, RowType, WatchChanges } from 'igniteui-angular/grids/core'; import { getCurrentI18n, getNumberFormatter, IResourceChangeEventArgs, } from 'igniteui-i18n-core'; import { I18N_FORMATTER, IgxStylesRegistrar } from 'igniteui-angular/core'; @@ -3467,7 +3473,12 @@ export abstract class IgxGridBaseDirective implements GridType, } constructor() { - inject(IgxStylesRegistrar).register(GRID_STYLES_ID, GRID_BASE_CSS); + const styles = inject(IgxStylesRegistrar); + styles.register(GRID_STYLES_ID, GRID_BASE_CSS); + styles.retain( + IgxBadgeComponent, IgxChipComponent, IgxCheckboxComponent, + IgxInputGroupComponent, IgxDatePickerComponent, IgxCircularProgressBarComponent + ); this.initLocale(); this._transactions = this.transactionFactory.create(TRANSACTION_TYPE.None); this._transactions.cloneStrategy = this.dataCloneStrategy; diff --git a/projects/igniteui-angular/grids/themes/_base.scss b/projects/igniteui-angular/grids/themes/_base.scss index 5f53c341efd..84e7a799420 100644 --- a/projects/igniteui-angular/grids/themes/_base.scss +++ b/projects/igniteui-angular/grids/themes/_base.scss @@ -2564,7 +2564,9 @@ flex-flow: column nowrap; position: relative; - :where(.igx-grid-th, .igx-grid-thead__group) { + // Split :where()s, a multi-argument subject defeats Chromium's rule bucketing. + :where(.igx-grid-th), + :where(.igx-grid-thead__group) { flex: 1 1 auto; } diff --git a/projects/igniteui-angular/input-group/src/input-group/input-group.component.spec.ts b/projects/igniteui-angular/input-group/src/input-group/input-group.component.spec.ts index daff72f3cae..93e7bb80e3b 100644 --- a/projects/igniteui-angular/input-group/src/input-group/input-group.component.spec.ts +++ b/projects/igniteui-angular/input-group/src/input-group/input-group.component.spec.ts @@ -1,11 +1,12 @@ -import { Component, ViewChild, ElementRef, inject, ChangeDetectionStrategy } from '@angular/core'; +import { Component, ViewChild, ElementRef, inject, ChangeDetectionStrategy, createComponent, EnvironmentInjector } from '@angular/core'; import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { IgxInputGroupComponent } from './input-group.component'; import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; import { IgxInputDirective, IgxPrefixDirective, IgxSuffixDirective } from '../public_api'; import { IGX_INPUT_GROUP_TYPE, IgxInputGroupType } from './inputGroupType'; -import { InputResourceStringsEN, changei18n } from 'igniteui-angular/core'; +import { InputResourceStringsEN, changei18n, THEME_TOKEN } from 'igniteui-angular/core'; +import { config } from 'rxjs'; const INPUT_GROUP_CSS_CLASS = 'igx-input-group'; const INPUT_GROUP_BOX_CSS_CLASS = 'igx-input-group--box'; @@ -259,6 +260,24 @@ describe('IgxInputGroup', () => { expect(fixture.componentInstance.materialGroup.isTypeIndigo).toBe(false); })); + it('should not throw on theme change when no igxInput is projected', async () => { + const errors: unknown[] = []; + const previous = config.onUnhandledError; + config.onUnhandledError = error => errors.push(error); + const token = TestBed.inject(THEME_TOKEN); + // Mirrors the detached instance IgxStylesRegistrar.retain() creates + const ref = createComponent(IgxInputGroupComponent, { environmentInjector: TestBed.inject(EnvironmentInjector) }); + + try { + token.set(token.theme === 'fluent' ? 'material' : 'fluent'); + await new Promise(resolve => setTimeout(resolve)); + expect(errors).toEqual([]); + } finally { + config.onUnhandledError = previous; + ref.destroy(); + } + }); + describe('Resource Strings', () => { it('should return full resource strings when partial resourceStrings are set', () => { const fix = TestBed.createComponent(InputGroupComponent); diff --git a/projects/igniteui-angular/input-group/src/input-group/input-group.component.ts b/projects/igniteui-angular/input-group/src/input-group/input-group.component.ts index 10f082748e7..661d74c5270 100644 --- a/projects/igniteui-angular/input-group/src/input-group/input-group.component.ts +++ b/projects/igniteui-angular/input-group/src/input-group/input-group.component.ts @@ -390,13 +390,13 @@ export class IgxInputGroupComponent implements IgxInputGroupBase, AfterContentCh /** @hidden @internal */ @HostBinding('class.igx-input-group--file') public get isFileType() { - return this.input.type === 'file'; + return this.input?.type === 'file'; } /** @hidden @internal */ @HostBinding('class.igx-file-input') public get isFileInput() { - return this.input.type === 'file'; + return this.input?.type === 'file'; } /** @hidden @internal */ @@ -419,7 +419,7 @@ export class IgxInputGroupComponent implements IgxInputGroupBase, AfterContentCh /** @hidden @internal */ public get fileNames() { - return this.input.fileNames || this.resourceStrings.igx_input_file_placeholder; + return this.input?.fileNames || this.resourceStrings.igx_input_file_placeholder; } /**