diff --git a/.gitignore b/.gitignore index ebf053049e..4f3a5d0dec 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ Thumbs.db firebase-debug.log .nx-cache .nx +.claude scully.log /.angular /.scully diff --git a/src/component-loader/component-loader.class.ts b/src/component-loader/component-loader.class.ts index c9ef050e1a..74a386cc63 100644 --- a/src/component-loader/component-loader.class.ts +++ b/src/component-loader/component-loader.class.ts @@ -21,7 +21,7 @@ import { import { PositioningOptions, PositioningService } from 'ngx-bootstrap/positioning'; -import { listenToTriggersV2, registerEscClick, registerOutsideClick } from 'ngx-bootstrap/utils'; +import { listenToTriggersV2, registerEscClick, registerOutsideClick, runInZoneRootIfPresent } from 'ngx-bootstrap/utils'; import { ContentRef } from './content-ref.class'; import { ListenOptions } from './listen-options.model'; @@ -394,8 +394,10 @@ export class ComponentLoader { this._positioningRafId = requestAnimationFrame(schedulePositioning); }; - // Initial calculation after a short delay to ensure DOM is ready - this._positioningRafId = requestAnimationFrame(schedulePositioning); + // The loop re-schedules itself every frame while the component is attached, so in + // zone.js apps it must start outside the Angular zone or every frame triggers + // app-wide change detection. Re-scheduled frames inherit the root zone from here. + this._positioningRafId = runInZoneRootIfPresent(() => requestAnimationFrame(schedulePositioning)); } private _unsubscribePositioning(): void { diff --git a/src/positioning/positioning.service.ts b/src/positioning/positioning.service.ts index 96aaaea727..589628870a 100644 --- a/src/positioning/positioning.service.ts +++ b/src/positioning/positioning.service.ts @@ -2,6 +2,8 @@ import { Injectable, ElementRef, RendererFactory2, Inject, PLATFORM_ID } from '@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { isPlatformBrowser } from '@angular/common'; +import { runInZoneRootIfPresent } from 'ngx-bootstrap/utils'; + import { positionElements } from './ng-positioning'; import { fromEvent, merge, of, animationFrameScheduler, Subject, Observable } from 'rxjs'; @@ -55,8 +57,6 @@ export class PositioningService { ) { if (isPlatformBrowser(platformId)) { - // Zoneless-compatible: no NgZone dependency needed - // Event listeners run without triggering change detection this.triggerEvent$ = merge( fromEvent(window, 'scroll', { passive: true }), fromEvent(window, 'resize', { passive: true }), @@ -64,23 +64,25 @@ export class PositioningService { this.update$$ ); - this.triggerEvent$.pipe(takeUntilDestroyed()).subscribe(() => { - if (this.isDisabled) { - return; - } - - this.positionElements - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .forEach((positionElement: any) => { - positionElements( - _getHtmlElement(positionElement.target), - _getHtmlElement(positionElement.element), - positionElement.attachment, - positionElement.appendToBody, - this.options, - rendererFactory.createRenderer(null, null) - ); - }); + runInZoneRootIfPresent(() => { + this.triggerEvent$?.pipe(takeUntilDestroyed()).subscribe(() => { + if (this.isDisabled) { + return; + } + + this.positionElements + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .forEach((positionElement: any) => { + positionElements( + _getHtmlElement(positionElement.target), + _getHtmlElement(positionElement.element), + positionElement.attachment, + positionElement.appendToBody, + this.options, + rendererFactory.createRenderer(null, null) + ); + }); + }); }); } } diff --git a/src/utils/public_api.ts b/src/utils/public_api.ts index 752790c326..165ce32df3 100644 --- a/src/utils/public_api.ts +++ b/src/utils/public_api.ts @@ -20,6 +20,7 @@ export { Trigger } from './trigger.class'; export { Utils } from './utils.class'; export { window, document } from './facade/browser'; export { warnOnce }from './warn-once'; +export { runInZoneRootIfPresent } from './zone-root'; export { animateExpand, onTransitionFinished, diff --git a/src/utils/zone-root.ts b/src/utils/zone-root.ts new file mode 100644 index 0000000000..29d9dda49a --- /dev/null +++ b/src/utils/zone-root.ts @@ -0,0 +1,23 @@ +interface ZoneStatic { + current: unknown; + root: { run(fn: () => T): T }; +} + +/** + * Runs `fn` in the zone.js root zone when zone.js is present, so that async tasks + * scheduled inside (event listeners, animation frames, timers) do not trigger + * Angular change detection. In zoneless apps `Zone` is undefined and `fn` runs as-is. + * + * Use for high-frequency work (scroll/resize listeners, rAF loops) that only mutates + * the DOM directly and never needs change detection. Unlike `NgZone.runOutsideAngular` + * this keeps libraries free of an NgZone dependency. + */ +export function runInZoneRootIfPresent(fn: () => T): T { + const zone = (globalThis as { Zone?: ZoneStatic }).Zone; + + if (zone && zone.current !== zone.root) { + return zone.root.run(fn); + } + + return fn(); +}