Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Thumbs.db
firebase-debug.log
.nx-cache
.nx
.claude
scully.log
/.angular
/.scully
Expand Down
8 changes: 5 additions & 3 deletions src/component-loader/component-loader.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -394,8 +394,10 @@ export class ComponentLoader<T extends object> {
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 {
Expand Down
40 changes: 21 additions & 19 deletions src/positioning/positioning.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -55,32 +57,32 @@ 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 }),
of(0, animationFrameScheduler),
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)
);
});
});
});
}
}
Expand Down
1 change: 1 addition & 0 deletions src/utils/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions src/utils/zone-root.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
interface ZoneStatic {
current: unknown;
root: { run<T>(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<T>(fn: () => T): T {
const zone = (globalThis as { Zone?: ZoneStatic }).Zone;

if (zone && zone.current !== zone.root) {
return zone.root.run(fn);
}

return fn();
}
Loading