diff --git a/apps/test-app/src/app/app.component.ts b/apps/test-app/src/app/app.component.ts
index 51b3c949..33170e71 100644
--- a/apps/test-app/src/app/app.component.ts
+++ b/apps/test-app/src/app/app.component.ts
@@ -31,6 +31,10 @@ import { RouterLink, RouterOutlet } from '@angular/router';
Active Element
+
+
+ Fps
+
diff --git a/apps/test-app/src/app/app.config.ts b/apps/test-app/src/app/app.config.ts
index 1470b211..d66086ee 100644
--- a/apps/test-app/src/app/app.config.ts
+++ b/apps/test-app/src/app/app.config.ts
@@ -24,6 +24,10 @@ export const appConfig: ApplicationConfig = {
path: 'drag',
loadComponent: () => import('./drag/drag.component'),
},
+ {
+ path: 'fps',
+ loadComponent: () => import('./inject-fps/inject-fps.component'),
+ },
{
path: 'active-element',
loadComponent: () =>
diff --git a/apps/test-app/src/app/inject-fps/inject-fps.component.ts b/apps/test-app/src/app/inject-fps/inject-fps.component.ts
new file mode 100644
index 00000000..b3ff23ff
--- /dev/null
+++ b/apps/test-app/src/app/inject-fps/inject-fps.component.ts
@@ -0,0 +1,23 @@
+import { Component, ElementRef, ViewChild, effect } from '@angular/core';
+import { injectFps } from 'ngxtension/inject-fps';
+
+@Component({
+ standalone: true,
+ host: {
+ style: 'display: block; margin: 12px',
+ },
+ template: `
+
+ `,
+})
+export default class InjectFpsCmp {
+ fps = injectFps();
+
+ @ViewChild('fpsDiv', { static: true }) fpsDiv!: ElementRef;
+
+ constructor() {
+ effect(() => {
+ this.fpsDiv.nativeElement.innerHTML = `FPS: ${this.fps()}`;
+ });
+ }
+}
diff --git a/libs/ngxtension/inject-fps/README.md b/libs/ngxtension/inject-fps/README.md
new file mode 100644
index 00000000..eb3c4393
--- /dev/null
+++ b/libs/ngxtension/inject-fps/README.md
@@ -0,0 +1,3 @@
+# ngxtension/inject-fps
+
+Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/inject-fps`.
diff --git a/libs/ngxtension/inject-fps/ng-package.json b/libs/ngxtension/inject-fps/ng-package.json
new file mode 100644
index 00000000..b3e53d69
--- /dev/null
+++ b/libs/ngxtension/inject-fps/ng-package.json
@@ -0,0 +1,5 @@
+{
+ "lib": {
+ "entryFile": "src/index.ts"
+ }
+}
diff --git a/libs/ngxtension/inject-fps/project.json b/libs/ngxtension/inject-fps/project.json
new file mode 100644
index 00000000..5390cc2d
--- /dev/null
+++ b/libs/ngxtension/inject-fps/project.json
@@ -0,0 +1,33 @@
+{
+ "name": "ngxtension/inject-fps",
+ "$schema": "../../../node_modules/nx/schemas/project-schema.json",
+ "projectType": "library",
+ "sourceRoot": "libs/ngxtension/inject-fps/src",
+ "targets": {
+ "test": {
+ "executor": "@nx/jest:jest",
+ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
+ "options": {
+ "jestConfig": "libs/ngxtension/jest.config.ts",
+ "testPathPattern": ["inject-fps"],
+ "passWithNoTests": true
+ },
+ "configurations": {
+ "ci": {
+ "ci": true,
+ "codeCoverage": true
+ }
+ }
+ },
+ "lint": {
+ "executor": "@nx/eslint:lint",
+ "outputs": ["{options.outputFile}"],
+ "options": {
+ "lintFilePatterns": [
+ "libs/ngxtension/inject-fps/**/*.ts",
+ "libs/ngxtension/inject-fps/**/*.html"
+ ]
+ }
+ }
+ }
+}
diff --git a/libs/ngxtension/inject-fps/src/index.ts b/libs/ngxtension/inject-fps/src/index.ts
new file mode 100644
index 00000000..fb504fba
--- /dev/null
+++ b/libs/ngxtension/inject-fps/src/index.ts
@@ -0,0 +1 @@
+export * from './inject-fps';
diff --git a/libs/ngxtension/inject-fps/src/inject-fps.ts b/libs/ngxtension/inject-fps/src/inject-fps.ts
new file mode 100644
index 00000000..ba87c8f4
--- /dev/null
+++ b/libs/ngxtension/inject-fps/src/inject-fps.ts
@@ -0,0 +1,51 @@
+import {
+ runInInjectionContext,
+ signal,
+ type Injector,
+ type Signal,
+} from '@angular/core';
+import { assertInjector } from 'ngxtension/assert-injector';
+import { injectRafFn } from 'ngxtension/inject-raf-fn';
+
+export interface InjectFpsOptions {
+ /**
+ * Calculate the FPS on every x frames.
+ * @default 10
+ */
+ every?: number;
+
+ /**
+ * The injector to use.
+ */
+ injector?: Injector;
+}
+
+export function injectFps(options?: InjectFpsOptions): Signal {
+ const injector = assertInjector(injectFps, options?.injector);
+
+ return runInInjectionContext(injector, () => {
+ const fps = signal(0);
+ if (typeof performance === 'undefined') return fps.asReadonly();
+
+ const every = options?.every ?? 10;
+
+ let last = performance.now();
+ let ticks = 0;
+
+ injectRafFn(
+ () => {
+ ticks += 1;
+ if (ticks >= every) {
+ const now = performance.now();
+ const diff = now - last;
+ fps.set(Math.round(1000 / (diff / ticks)));
+ last = now;
+ ticks = 0;
+ }
+ },
+ { injector }
+ );
+
+ return fps;
+ });
+}
diff --git a/libs/ngxtension/inject-raf-fn/README.md b/libs/ngxtension/inject-raf-fn/README.md
new file mode 100644
index 00000000..b9a488a0
--- /dev/null
+++ b/libs/ngxtension/inject-raf-fn/README.md
@@ -0,0 +1,3 @@
+# ngxtension/inject-raf-fn
+
+Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/inject-raf-fn`.
diff --git a/libs/ngxtension/inject-raf-fn/ng-package.json b/libs/ngxtension/inject-raf-fn/ng-package.json
new file mode 100644
index 00000000..b3e53d69
--- /dev/null
+++ b/libs/ngxtension/inject-raf-fn/ng-package.json
@@ -0,0 +1,5 @@
+{
+ "lib": {
+ "entryFile": "src/index.ts"
+ }
+}
diff --git a/libs/ngxtension/inject-raf-fn/project.json b/libs/ngxtension/inject-raf-fn/project.json
new file mode 100644
index 00000000..b6ca401a
--- /dev/null
+++ b/libs/ngxtension/inject-raf-fn/project.json
@@ -0,0 +1,33 @@
+{
+ "name": "ngxtension/inject-raf-fn",
+ "$schema": "../../../node_modules/nx/schemas/project-schema.json",
+ "projectType": "library",
+ "sourceRoot": "libs/ngxtension/inject-raf-fn/src",
+ "targets": {
+ "test": {
+ "executor": "@nx/jest:jest",
+ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
+ "options": {
+ "jestConfig": "libs/ngxtension/jest.config.ts",
+ "testPathPattern": ["inject-raf-fn"],
+ "passWithNoTests": true
+ },
+ "configurations": {
+ "ci": {
+ "ci": true,
+ "codeCoverage": true
+ }
+ }
+ },
+ "lint": {
+ "executor": "@nx/eslint:lint",
+ "outputs": ["{options.outputFile}"],
+ "options": {
+ "lintFilePatterns": [
+ "libs/ngxtension/inject-raf-fn/**/*.ts",
+ "libs/ngxtension/inject-raf-fn/**/*.html"
+ ]
+ }
+ }
+ }
+}
diff --git a/libs/ngxtension/inject-raf-fn/src/index.ts b/libs/ngxtension/inject-raf-fn/src/index.ts
new file mode 100644
index 00000000..2e8261cc
--- /dev/null
+++ b/libs/ngxtension/inject-raf-fn/src/index.ts
@@ -0,0 +1 @@
+export * from './inject-raf-fn';
diff --git a/libs/ngxtension/inject-raf-fn/src/inject-raf-fn.ts b/libs/ngxtension/inject-raf-fn/src/inject-raf-fn.ts
new file mode 100644
index 00000000..b3442ab3
--- /dev/null
+++ b/libs/ngxtension/inject-raf-fn/src/inject-raf-fn.ts
@@ -0,0 +1,141 @@
+import { isPlatformServer } from '@angular/common';
+import {
+ NgZone,
+ PLATFORM_ID,
+ inject,
+ runInInjectionContext,
+ signal,
+ type Injector,
+ type Signal,
+} from '@angular/core';
+import { assertInjector } from 'ngxtension/assert-injector';
+
+export interface InjectRafFnCallbackArguments {
+ /**
+ * Time elapsed between this and the last frame.
+ */
+ delta: number;
+
+ /**
+ * Time elapsed since the creation of the web page. See {@link https://developer.mozilla.org/en-US/docs/Web/API/DOMHighResTimeStamp#the_time_origin Time origin}.
+ */
+ timestamp: DOMHighResTimeStamp;
+}
+
+export interface UseRafFnOptions {
+ /**
+ * Start the requestAnimationFrame loop immediately on creation
+ *
+ * @default true
+ */
+ immediate?: boolean;
+ /**
+ * The maximum frame per second to execute the function.
+ * Set to `undefined` to disable the limit.
+ *
+ * @default undefined
+ */
+ fpsLimit?: number;
+
+ /**
+ * The injector to use.
+ */
+ injector?: Injector;
+}
+
+export interface RafFn {
+ /**
+ * A signal to indicate whether an instance is active
+ */
+ isActive: Signal;
+
+ /**
+ * Temporary pause the effect from executing
+ */
+ pause: () => void;
+
+ /**
+ * Resume the effects
+ */
+ resume: () => void;
+}
+
+/**
+ * Call function on every `requestAnimationFrame`. With controls of pausing and resuming.
+ *
+ * @param fn
+ * @param options
+ */
+export function injectRafFn(
+ fn: (args: InjectRafFnCallbackArguments) => void,
+ options: UseRafFnOptions = {}
+): RafFn {
+ const injector = assertInjector(injectRafFn, options?.injector);
+
+ return runInInjectionContext(injector, () => {
+ const ngZone = inject(NgZone);
+ const isServer = isPlatformServer(inject(PLATFORM_ID));
+
+ if (isServer) {
+ return {
+ isActive: signal(false).asReadonly(),
+ pause: () => {},
+ resume: () => {},
+ };
+ }
+
+ const { immediate = true, fpsLimit = undefined } = options;
+
+ const isActive = signal(false);
+
+ const intervalLimit = fpsLimit ? 1000 / fpsLimit : null;
+ let previousFrameTimestamp = 0;
+ let rafId: null | number = null;
+
+ const loop = (timestamp: DOMHighResTimeStamp) => {
+ if (!isActive() || !window) return;
+
+ const delta = timestamp - (previousFrameTimestamp || timestamp);
+
+ if (intervalLimit && delta < intervalLimit) {
+ rafId = ngZone.runOutsideAngular(() =>
+ window.requestAnimationFrame(loop)
+ );
+ return;
+ }
+
+ fn({ delta, timestamp });
+
+ previousFrameTimestamp = timestamp;
+
+ rafId = ngZone.runOutsideAngular(() =>
+ window.requestAnimationFrame(loop)
+ );
+ };
+
+ const resume = () => {
+ if (!isActive() && window) {
+ isActive.set(true);
+ rafId = ngZone.runOutsideAngular(() =>
+ window.requestAnimationFrame(loop)
+ );
+ }
+ };
+
+ const pause = () => {
+ isActive.set(false);
+ if (rafId && typeof rafId === 'number' && window) {
+ ngZone.runOutsideAngular(() => window.cancelAnimationFrame(rafId!));
+ rafId = null;
+ }
+ };
+
+ if (immediate) resume();
+
+ return {
+ isActive: isActive.asReadonly(),
+ pause,
+ resume,
+ };
+ });
+}
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 9a54f9c9..1514a0d7 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -52,6 +52,7 @@
"ngxtension/inject-destroy": [
"libs/ngxtension/inject-destroy/src/index.ts"
],
+ "ngxtension/inject-fps": ["libs/ngxtension/inject-fps/src/index.ts"],
"ngxtension/inject-is-intersecting": [
"libs/ngxtension/inject-is-intersecting/src/index.ts"
],
@@ -62,6 +63,9 @@
"ngxtension/inject-query-params": [
"libs/ngxtension/inject-query-params/src/index.ts"
],
+ "ngxtension/inject-raf-fn": [
+ "libs/ngxtension/inject-raf-fn/src/index.ts"
+ ],
"ngxtension/intl": ["libs/ngxtension/intl/src/index.ts"],
"ngxtension/map-array": ["libs/ngxtension/map-array/src/index.ts"],
"ngxtension/map-skip-undefined": [