From fb56acdb772a4b3efcf6f7eb7354866f201eb851 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Sat, 19 Apr 2025 11:42:18 +0200 Subject: [PATCH] perf(cdk/overlay): add tree-shakeable alternatives for overlay APIs Currently all the overlay APIs go through the `Overlay` service which means that even if an app only uses the fairly simple global positioning strategy, they'd still bring in all the code for the complex flexible positioning strategy. These changes break up the APIs into constructor functions that can be tree shaken separately. Note that I'll send follow-up PRs to roll this out in Material in order to see the full benefits. From a simple test of a an `ng new` app that only uses `MatTooltip`, this shaved off ~10kb of minified JS. --- goldens/cdk/overlay/index.api.md | 21 +++ .../fullscreen-overlay-container.spec.ts | 9 +- src/cdk/overlay/overlay.ts | 130 +++++++----------- .../flexible-connected-position-strategy.ts | 20 ++- .../position/global-position-strategy.ts | 11 ++ .../position/overlay-position-builder.ts | 24 +--- src/cdk/overlay/public-api.ts | 8 +- .../overlay/scroll/block-scroll-strategy.ts | 10 ++ .../overlay/scroll/close-scroll-strategy.ts | 19 ++- src/cdk/overlay/scroll/index.ts | 7 +- .../overlay/scroll/noop-scroll-strategy.ts | 5 + .../scroll/reposition-scroll-strategy.ts | 19 ++- .../overlay/scroll/scroll-strategy-options.ts | 23 ++-- 13 files changed, 180 insertions(+), 126 deletions(-) diff --git a/goldens/cdk/overlay/index.api.md b/goldens/cdk/overlay/index.api.md index b43323a18566..26f46ecd8661 100644 --- a/goldens/cdk/overlay/index.api.md +++ b/goldens/cdk/overlay/index.api.md @@ -197,6 +197,27 @@ export class ConnectionPositionPair { panelClass?: string | string[] | undefined; } +// @public +export function createBlockScrollStrategy(injector: Injector): BlockScrollStrategy; + +// @public +export function createCloseScrollStrategy(injector: Injector, config?: CloseScrollStrategyConfig): CloseScrollStrategy; + +// @public +export function createFlexibleConnectedPositionStrategy(injector: Injector, origin: FlexibleConnectedPositionStrategyOrigin): FlexibleConnectedPositionStrategy; + +// @public +export function createGlobalPositionStrategy(_injector: Injector): GlobalPositionStrategy; + +// @public +export function createNoopScrollStrategy(): NoopScrollStrategy; + +// @public +export function createOverlayRef(injector: Injector, config?: OverlayConfig): OverlayRef; + +// @public +export function createRepositionScrollStrategy(injector: Injector, config?: RepositionScrollStrategyConfig): RepositionScrollStrategy; + // @public export class FlexibleConnectedPositionStrategy implements PositionStrategy { constructor(connectedTo: FlexibleConnectedPositionStrategyOrigin, _viewportRuler: ViewportRuler, _document: Document, _platform: Platform, _overlayContainer: OverlayContainer); diff --git a/src/cdk/overlay/fullscreen-overlay-container.spec.ts b/src/cdk/overlay/fullscreen-overlay-container.spec.ts index 77615ae877b7..3a9942acc111 100644 --- a/src/cdk/overlay/fullscreen-overlay-container.spec.ts +++ b/src/cdk/overlay/fullscreen-overlay-container.spec.ts @@ -23,7 +23,7 @@ describe('FullscreenOverlayContainer', () => { // stubs here, we should reconsider whether to use a Proxy instead. Avoiding a proxy for // now since it isn't supported on IE. See: // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy - fakeDocument = { + return { body: document.body, head: document.head, fullscreenElement: document.createElement('div'), @@ -51,20 +51,15 @@ describe('FullscreenOverlayContainer', () => { createTextNode: (...args: [string]) => document.createTextNode(...args), createComment: (...args: [string]) => document.createComment(...args), }; - - return fakeDocument; }, }, ], }); overlay = TestBed.inject(Overlay); + fakeDocument = TestBed.inject(DOCUMENT); })); - afterEach(() => { - fakeDocument = null; - }); - it('should open an overlay inside a fullscreen element and move it to the body', () => { const fixture = TestBed.createComponent(TestComponentWithTemplatePortals); fixture.detectChanges(); diff --git a/src/cdk/overlay/overlay.ts b/src/cdk/overlay/overlay.ts index 2ec785b52568..935196bd2c3e 100644 --- a/src/cdk/overlay/overlay.ts +++ b/src/cdk/overlay/overlay.ts @@ -19,6 +19,7 @@ import { inject, RendererFactory2, DOCUMENT, + Renderer2, } from '@angular/core'; import {_IdGenerator} from '../a11y'; import {_CdkPrivateStyleLoader} from '../private'; @@ -30,6 +31,56 @@ import {OverlayRef} from './overlay-ref'; import {OverlayPositionBuilder} from './position/overlay-position-builder'; import {ScrollStrategyOptions} from './scroll/index'; +/** + * Creates an overlay. + * @param injector Injector to use when resolving the overlay's dependencies. + * @param config Configuration applied to the overlay. + * @returns Reference to the created overlay. + */ +export function createOverlayRef(injector: Injector, config?: OverlayConfig): OverlayRef { + // This is done in the overlay container as well, but we have it here + // since it's common to mock out the overlay container in tests. + injector.get(_CdkPrivateStyleLoader).load(_CdkOverlayStyleLoader); + + const overlayContainer = injector.get(OverlayContainer); + const doc = injector.get(DOCUMENT); + const idGenerator = injector.get(_IdGenerator); + const appRef = injector.get(ApplicationRef); + const directionality = injector.get(Directionality); + + const host = doc.createElement('div'); + const pane = doc.createElement('div'); + + pane.id = idGenerator.getId('cdk-overlay-'); + pane.classList.add('cdk-overlay-pane'); + host.appendChild(pane); + overlayContainer.getContainerElement().appendChild(host); + + const portalOutlet = new DomPortalOutlet(pane, appRef, injector); + const overlayConfig = new OverlayConfig(config); + const renderer = + injector.get(Renderer2, null, {optional: true}) || + injector.get(RendererFactory2).createRenderer(null, null); + + overlayConfig.direction = overlayConfig.direction || directionality.value; + + return new OverlayRef( + portalOutlet, + host, + pane, + overlayConfig, + injector.get(NgZone), + injector.get(OverlayKeyboardDispatcher), + doc, + injector.get(Location), + injector.get(OverlayOutsideClickDispatcher), + config?.disableAnimations ?? + injector.get(ANIMATION_MODULE_TYPE, null, {optional: true}) === 'NoopAnimations', + injector.get(EnvironmentInjector), + renderer, + ); +} + /** * Service to create Overlays. Overlays are dynamically added pieces of floating UI, meant to be * used as a low-level building block for other components. Dialogs, tooltips, menus, @@ -41,21 +92,8 @@ import {ScrollStrategyOptions} from './scroll/index'; @Injectable({providedIn: 'root'}) export class Overlay { scrollStrategies = inject(ScrollStrategyOptions); - private _overlayContainer = inject(OverlayContainer); private _positionBuilder = inject(OverlayPositionBuilder); - private _keyboardDispatcher = inject(OverlayKeyboardDispatcher); private _injector = inject(Injector); - private _ngZone = inject(NgZone); - private _document = inject(DOCUMENT); - private _directionality = inject(Directionality); - private _location = inject(Location); - private _outsideClickDispatcher = inject(OverlayOutsideClickDispatcher); - private _animationsModuleType = inject(ANIMATION_MODULE_TYPE, {optional: true}); - private _idGenerator = inject(_IdGenerator); - private _renderer = inject(RendererFactory2).createRenderer(null, null); - - private _appRef: ApplicationRef; - private _styleLoader = inject(_CdkPrivateStyleLoader); constructor(...args: unknown[]); constructor() {} @@ -66,31 +104,7 @@ export class Overlay { * @returns Reference to the created overlay. */ create(config?: OverlayConfig): OverlayRef { - // This is done in the overlay container as well, but we have it here - // since it's common to mock out the overlay container in tests. - this._styleLoader.load(_CdkOverlayStyleLoader); - - const host = this._createHostElement(); - const pane = this._createPaneElement(host); - const portalOutlet = this._createPortalOutlet(pane); - const overlayConfig = new OverlayConfig(config); - - overlayConfig.direction = overlayConfig.direction || this._directionality.value; - - return new OverlayRef( - portalOutlet, - host, - pane, - overlayConfig, - this._ngZone, - this._keyboardDispatcher, - this._document, - this._location, - this._outsideClickDispatcher, - config?.disableAnimations ?? this._animationsModuleType === 'NoopAnimations', - this._injector.get(EnvironmentInjector), - this._renderer, - ); + return createOverlayRef(this._injector, config); } /** @@ -101,44 +115,4 @@ export class Overlay { position(): OverlayPositionBuilder { return this._positionBuilder; } - - /** - * Creates the DOM element for an overlay and appends it to the overlay container. - * @returns Newly-created pane element - */ - private _createPaneElement(host: HTMLElement): HTMLElement { - const pane = this._document.createElement('div'); - - pane.id = this._idGenerator.getId('cdk-overlay-'); - pane.classList.add('cdk-overlay-pane'); - host.appendChild(pane); - - return pane; - } - - /** - * Creates the host element that wraps around an overlay - * and can be used for advanced positioning. - * @returns Newly-create host element. - */ - private _createHostElement(): HTMLElement { - const host = this._document.createElement('div'); - this._overlayContainer.getContainerElement().appendChild(host); - return host; - } - - /** - * Create a DomPortalOutlet into which the overlay content can be loaded. - * @param pane The DOM element to turn into a portal outlet. - * @returns A portal outlet for the given DOM element. - */ - private _createPortalOutlet(pane: HTMLElement): DomPortalOutlet { - // We have to resolve the ApplicationRef later in order to allow people - // to use overlay-based providers during app initialization. - if (!this._appRef) { - this._appRef = this._injector.get(ApplicationRef); - } - - return new DomPortalOutlet(pane, this._appRef, this._injector); - } } diff --git a/src/cdk/overlay/position/flexible-connected-position-strategy.ts b/src/cdk/overlay/position/flexible-connected-position-strategy.ts index 7e14097ac4cd..21932086e888 100644 --- a/src/cdk/overlay/position/flexible-connected-position-strategy.ts +++ b/src/cdk/overlay/position/flexible-connected-position-strategy.ts @@ -7,7 +7,7 @@ */ import {PositionStrategy} from './position-strategy'; -import {ElementRef} from '@angular/core'; +import {DOCUMENT, ElementRef, Injector} from '@angular/core'; import {ViewportRuler, CdkScrollable, ViewportScrollPosition} from '../../scrolling'; import { ConnectedOverlayPositionChange, @@ -44,6 +44,24 @@ export type FlexibleConnectedPositionStrategyOrigin = /** Equivalent of `DOMRect` without some of the properties we don't care about. */ type Dimensions = Omit; +/** + * Creates a flexible position strategy. + * @param injector Injector used to resolve dependnecies for the position strategy. + * @param origin Origin relative to which to position the overlay. + */ +export function createFlexibleConnectedPositionStrategy( + injector: Injector, + origin: FlexibleConnectedPositionStrategyOrigin, +): FlexibleConnectedPositionStrategy { + return new FlexibleConnectedPositionStrategy( + origin, + injector.get(ViewportRuler), + injector.get(DOCUMENT), + injector.get(Platform), + injector.get(OverlayContainer), + ); +} + /** * A strategy for positioning overlays. Using this strategy, an overlay is given an * implicit position relative some origin element. The relative position is defined in terms of diff --git a/src/cdk/overlay/position/global-position-strategy.ts b/src/cdk/overlay/position/global-position-strategy.ts index 94481f034cff..c174079b2b4a 100644 --- a/src/cdk/overlay/position/global-position-strategy.ts +++ b/src/cdk/overlay/position/global-position-strategy.ts @@ -6,12 +6,23 @@ * found in the LICENSE file at https://angular.dev/license */ +import {Injector} from '@angular/core'; import {OverlayRef} from '../overlay-ref'; import {PositionStrategy} from './position-strategy'; /** Class to be added to the overlay pane wrapper. */ const wrapperClass = 'cdk-global-overlay-wrapper'; +/** + * Creates a global position strategy. + * @param injector Injector used to resolve dependencies for the strategy. + */ +export function createGlobalPositionStrategy(_injector: Injector): GlobalPositionStrategy { + // Note: `injector` is unused, but we may need it in + // the future which would introduce a breaking change. + return new GlobalPositionStrategy(); +} + /** * A strategy for positioning overlays. Using this strategy, an overlay is given an * explicit position relative to the browser's viewport. We use flexbox, instead of diff --git a/src/cdk/overlay/position/overlay-position-builder.ts b/src/cdk/overlay/position/overlay-position-builder.ts index 266dea042fab..671bbe2953da 100644 --- a/src/cdk/overlay/position/overlay-position-builder.ts +++ b/src/cdk/overlay/position/overlay-position-builder.ts @@ -6,24 +6,18 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Platform} from '../../platform'; -import {ViewportRuler} from '../../scrolling'; - -import {Injectable, inject, DOCUMENT} from '@angular/core'; -import {OverlayContainer} from '../overlay-container'; +import {Injectable, Injector, inject} from '@angular/core'; import { + createFlexibleConnectedPositionStrategy, FlexibleConnectedPositionStrategy, FlexibleConnectedPositionStrategyOrigin, } from './flexible-connected-position-strategy'; -import {GlobalPositionStrategy} from './global-position-strategy'; +import {createGlobalPositionStrategy, GlobalPositionStrategy} from './global-position-strategy'; /** Builder for overlay position strategy. */ @Injectable({providedIn: 'root'}) export class OverlayPositionBuilder { - private _viewportRuler = inject(ViewportRuler); - private _document = inject(DOCUMENT); - private _platform = inject(Platform); - private _overlayContainer = inject(OverlayContainer); + private _injector = inject(Injector); constructor(...args: unknown[]); constructor() {} @@ -32,7 +26,7 @@ export class OverlayPositionBuilder { * Creates a global position strategy. */ global(): GlobalPositionStrategy { - return new GlobalPositionStrategy(); + return createGlobalPositionStrategy(this._injector); } /** @@ -42,12 +36,6 @@ export class OverlayPositionBuilder { flexibleConnectedTo( origin: FlexibleConnectedPositionStrategyOrigin, ): FlexibleConnectedPositionStrategy { - return new FlexibleConnectedPositionStrategy( - origin, - this._viewportRuler, - this._document, - this._platform, - this._overlayContainer, - ); + return createFlexibleConnectedPositionStrategy(this._injector, origin); } } diff --git a/src/cdk/overlay/public-api.ts b/src/cdk/overlay/public-api.ts index 82367446c882..43a89dfdffea 100644 --- a/src/cdk/overlay/public-api.ts +++ b/src/cdk/overlay/public-api.ts @@ -11,7 +11,7 @@ export * from './position/connected-position'; export * from './scroll/index'; export * from './overlay-module'; export * from './dispatchers/index'; -export {Overlay} from './overlay'; +export {Overlay, createOverlayRef} from './overlay'; export {OverlayContainer} from './overlay-container'; export {CdkOverlayOrigin, CdkConnectedOverlay} from './overlay-directives'; export {FullscreenOverlayContainer} from './fullscreen-overlay-container'; @@ -22,11 +22,15 @@ export {OverlayPositionBuilder} from './position/overlay-position-builder'; // Export pre-defined position strategies and interface to build custom ones. export {PositionStrategy} from './position/position-strategy'; -export {GlobalPositionStrategy} from './position/global-position-strategy'; +export { + GlobalPositionStrategy, + createGlobalPositionStrategy, +} from './position/global-position-strategy'; export { ConnectedPosition, FlexibleConnectedPositionStrategy, FlexibleConnectedPositionStrategyOrigin, STANDARD_DROPDOWN_ADJACENT_POSITIONS, STANDARD_DROPDOWN_BELOW_POSITIONS, + createFlexibleConnectedPositionStrategy, } from './position/flexible-connected-position-strategy'; diff --git a/src/cdk/overlay/scroll/block-scroll-strategy.ts b/src/cdk/overlay/scroll/block-scroll-strategy.ts index 5ebff3d04a5d..a481f1b81f9a 100644 --- a/src/cdk/overlay/scroll/block-scroll-strategy.ts +++ b/src/cdk/overlay/scroll/block-scroll-strategy.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import {DOCUMENT, Injector} from '@angular/core'; import {ScrollStrategy} from './scroll-strategy'; import {ViewportRuler} from '../../scrolling'; import {coerceCssPixelValue} from '../../coercion'; @@ -13,6 +14,15 @@ import {supportsScrollBehavior} from '../../platform'; const scrollBehaviorSupported = supportsScrollBehavior(); +/** + * Creates a scroll strategy that prevents the user from scrolling while the overlay is open. + * @param injector Injector used to resolve dependencies of the scroll strategy. + * @param config Configuration options for the scroll strategy. + */ +export function createBlockScrollStrategy(injector: Injector): BlockScrollStrategy { + return new BlockScrollStrategy(injector.get(ViewportRuler), injector.get(DOCUMENT)); +} + /** * Strategy that will prevent the user from scrolling while the overlay is visible. */ diff --git a/src/cdk/overlay/scroll/close-scroll-strategy.ts b/src/cdk/overlay/scroll/close-scroll-strategy.ts index 027a53526c32..2703e386352d 100644 --- a/src/cdk/overlay/scroll/close-scroll-strategy.ts +++ b/src/cdk/overlay/scroll/close-scroll-strategy.ts @@ -5,7 +5,7 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ -import {NgZone} from '@angular/core'; +import {Injector, NgZone} from '@angular/core'; import {ScrollStrategy, getMatScrollStrategyAlreadyAttachedError} from './scroll-strategy'; import {Subscription} from 'rxjs'; import {ScrollDispatcher, ViewportRuler} from '../../scrolling'; @@ -20,6 +20,23 @@ export interface CloseScrollStrategyConfig { threshold?: number; } +/** + * Creates a scroll strategy that closes the overlay when the user starts to scroll. + * @param injector Injector used to resolve dependencies of the scroll strategy. + * @param config Configuration options for the scroll strategy. + */ +export function createCloseScrollStrategy( + injector: Injector, + config?: CloseScrollStrategyConfig, +): CloseScrollStrategy { + return new CloseScrollStrategy( + injector.get(ScrollDispatcher), + injector.get(NgZone), + injector.get(ViewportRuler), + config, + ); +} + /** * Strategy that will close the overlay as soon as the user starts scrolling. */ diff --git a/src/cdk/overlay/scroll/index.ts b/src/cdk/overlay/scroll/index.ts index 82b4ca51aa2e..47a3d846beb6 100644 --- a/src/cdk/overlay/scroll/index.ts +++ b/src/cdk/overlay/scroll/index.ts @@ -14,7 +14,8 @@ export {ScrollStrategyOptions} from './scroll-strategy-options'; export { RepositionScrollStrategy, RepositionScrollStrategyConfig, + createRepositionScrollStrategy, } from './reposition-scroll-strategy'; -export {CloseScrollStrategy} from './close-scroll-strategy'; -export {NoopScrollStrategy} from './noop-scroll-strategy'; -export {BlockScrollStrategy} from './block-scroll-strategy'; +export {CloseScrollStrategy, createCloseScrollStrategy} from './close-scroll-strategy'; +export {NoopScrollStrategy, createNoopScrollStrategy} from './noop-scroll-strategy'; +export {BlockScrollStrategy, createBlockScrollStrategy} from './block-scroll-strategy'; diff --git a/src/cdk/overlay/scroll/noop-scroll-strategy.ts b/src/cdk/overlay/scroll/noop-scroll-strategy.ts index 3ed8f3ef8981..583b6468938f 100644 --- a/src/cdk/overlay/scroll/noop-scroll-strategy.ts +++ b/src/cdk/overlay/scroll/noop-scroll-strategy.ts @@ -8,6 +8,11 @@ import {ScrollStrategy} from './scroll-strategy'; +/** Creates a scroll strategy that does nothing. */ +export function createNoopScrollStrategy(): NoopScrollStrategy { + return new NoopScrollStrategy(); +} + /** Scroll strategy that doesn't do anything. */ export class NoopScrollStrategy implements ScrollStrategy { /** Does nothing, as this scroll strategy is a no-op. */ diff --git a/src/cdk/overlay/scroll/reposition-scroll-strategy.ts b/src/cdk/overlay/scroll/reposition-scroll-strategy.ts index 73596477e3a7..1c30c1c6e8b9 100644 --- a/src/cdk/overlay/scroll/reposition-scroll-strategy.ts +++ b/src/cdk/overlay/scroll/reposition-scroll-strategy.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {NgZone} from '@angular/core'; +import {Injector, NgZone} from '@angular/core'; import {Subscription} from 'rxjs'; import {ScrollStrategy, getMatScrollStrategyAlreadyAttachedError} from './scroll-strategy'; import {ScrollDispatcher, ViewportRuler} from '../../scrolling'; @@ -24,6 +24,23 @@ export interface RepositionScrollStrategyConfig { autoClose?: boolean; } +/** + * Creates a scroll strategy that updates the overlay's position when the user scrolls. + * @param injector Injector used to resolve dependencies of the scroll strategy. + * @param config Configuration options for the scroll strategy. + */ +export function createRepositionScrollStrategy( + injector: Injector, + config?: RepositionScrollStrategyConfig, +): RepositionScrollStrategy { + return new RepositionScrollStrategy( + injector.get(ScrollDispatcher), + injector.get(ViewportRuler), + injector.get(NgZone), + config, + ); +} + /** * Strategy that will update the element position as the user is scrolling. */ diff --git a/src/cdk/overlay/scroll/scroll-strategy-options.ts b/src/cdk/overlay/scroll/scroll-strategy-options.ts index 3f49b7a0d568..d57971387339 100644 --- a/src/cdk/overlay/scroll/scroll-strategy-options.ts +++ b/src/cdk/overlay/scroll/scroll-strategy-options.ts @@ -6,14 +6,12 @@ * found in the LICENSE file at https://angular.dev/license */ -import {ScrollDispatcher, ViewportRuler} from '../../scrolling'; - -import {Injectable, NgZone, inject, DOCUMENT} from '@angular/core'; -import {BlockScrollStrategy} from './block-scroll-strategy'; -import {CloseScrollStrategy, CloseScrollStrategyConfig} from './close-scroll-strategy'; +import {Injectable, Injector, inject} from '@angular/core'; +import {createBlockScrollStrategy} from './block-scroll-strategy'; +import {CloseScrollStrategyConfig, createCloseScrollStrategy} from './close-scroll-strategy'; import {NoopScrollStrategy} from './noop-scroll-strategy'; import { - RepositionScrollStrategy, + createRepositionScrollStrategy, RepositionScrollStrategyConfig, } from './reposition-scroll-strategy'; @@ -25,11 +23,7 @@ import { */ @Injectable({providedIn: 'root'}) export class ScrollStrategyOptions { - private _scrollDispatcher = inject(ScrollDispatcher); - private _viewportRuler = inject(ViewportRuler); - private _ngZone = inject(NgZone); - - private _document = inject(DOCUMENT); + private _injector = inject(Injector); constructor(...args: unknown[]); constructor() {} @@ -41,11 +35,10 @@ export class ScrollStrategyOptions { * Close the overlay as soon as the user scrolls. * @param config Configuration to be used inside the scroll strategy. */ - close = (config?: CloseScrollStrategyConfig) => - new CloseScrollStrategy(this._scrollDispatcher, this._ngZone, this._viewportRuler, config); + close = (config?: CloseScrollStrategyConfig) => createCloseScrollStrategy(this._injector, config); /** Block scrolling. */ - block = () => new BlockScrollStrategy(this._viewportRuler, this._document); + block = () => createBlockScrollStrategy(this._injector); /** * Update the overlay's position on scroll. @@ -53,5 +46,5 @@ export class ScrollStrategyOptions { * Allows debouncing the reposition calls. */ reposition = (config?: RepositionScrollStrategyConfig) => - new RepositionScrollStrategy(this._scrollDispatcher, this._viewportRuler, this._ngZone, config); + createRepositionScrollStrategy(this._injector, config); }