Skip to content
Merged
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
51 changes: 33 additions & 18 deletions projects/ngneat/helipopper/src/lib/tippy.factory.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,55 @@
import type tippy from 'tippy.js';
import { inject, Injectable, NgZone } from '@angular/core';
import { defer, from, map, type Observable, of, shareReplay } from 'rxjs';
import { inject, Injectable, NgZone, ɵisPromise as isPromise } from '@angular/core';
import { defer, map, Observable, of, tap } from 'rxjs';
import { TIPPY_LOADER, type TippyProps } from '@ngneat/helipopper/config';

// We need to use `isPromise` instead of checking whether
// `value instanceof Promise`. In zone.js patched environments, `global.Promise`
// is the `ZoneAwarePromise`.
// `import(...)` returns a native promise (not a `ZoneAwarePromise`), causing
// `instanceof` check to be falsy.
function isPromise<T>(value: any): value is Promise<T> {
return typeof value?.then === 'function';
}

@Injectable({ providedIn: 'root' })
export class TippyFactory {
private readonly _ngZone = inject(NgZone);

private readonly _loader = inject(TIPPY_LOADER);

private _tippyImpl$: Observable<typeof tippy> | null = null;
private _tippy: typeof tippy | null = null;

/**
* This returns an observable because the user should provide a `loader`
* function, which may return a promise if the tippy.js library is to be
* loaded asynchronously.
*/
create(target: HTMLElement, props?: Partial<TippyProps>) {
// We use `shareReplay` to ensure that subsequent emissions are
// synchronous and to avoid triggering the `defer` callback repeatedly
// when new subscribers arrive.
this._tippyImpl$ ||= defer(() => {
if (this._tippy) return of(this._tippy);

// Call the `loader` function lazily — only when a subscriber
// arrives — to avoid importing `tippy.js` on the server.
const maybeTippy = this._ngZone.runOutsideAngular(() => this._loader());
return isPromise(maybeTippy)
? from(maybeTippy).pipe(map((tippy) => tippy.default))
: of(maybeTippy);
}).pipe(shareReplay());

let tippy$: Observable<typeof tippy>;
// We need to use `isPromise` instead of checking whether
// `result instanceof Promise`. In zone.js patched environments, `global.Promise`
// is the `ZoneAwarePromise`. Some APIs, which are likely not patched by zone.js
// for certain reasons, might not work with `instanceof`. For instance, the dynamic
// import `() => import('./chunk.js')` returns a native promise (not a `ZoneAwarePromise`),
// causing this check to be falsy.
if (isPromise(maybeTippy)) {
// This pulls less RxJS symbols compared to using `from()` to resolve a promise value.
tippy$ = new Observable((subscriber) => {
maybeTippy.then((tippy) => {
subscriber.next(tippy.default);
subscriber.complete();
});
});
} else {
tippy$ = of(maybeTippy);
}

return tippy$.pipe(
tap((tippy) => {
this._tippy = tippy;
})
);
});

return this._tippyImpl$.pipe(
map((tippy) => {
Expand Down