Skip to content

Commit

Permalink
perf(toolkit/observable): optimize position detection in `fromBoundin…
Browse files Browse the repository at this point in the history
…gClientRect$`

There is no native browser API for observing an element's position. The new implementation uses the Intersection Observer API to detect position changes, significantly improving performance compared to the previous approach, which required monitoring numerous DOM elements for resizing, scrolling, and DOM changes.
  • Loading branch information
danielwiehl committed Sep 30, 2024
1 parent 83c4b3c commit 9eea1a3
Show file tree
Hide file tree
Showing 39 changed files with 1,720 additions and 456 deletions.
10 changes: 7 additions & 3 deletions apps/components-testing-app/src/app/app.routes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2018-2023 Swiss Federal Railways
* Copyright (c) 2018-2024 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
Expand All @@ -12,7 +12,11 @@ import {Routes} from '@angular/router';

export const routes: Routes = [
{
path: 'sci-viewport',
loadChildren: () => import('./sci-viewport/routes'),
path: 'components',
loadChildren: () => import('./components/routes'),
},
{
path: 'toolkit',
loadChildren: () => import('./toolkit/routes'),
},
];
18 changes: 18 additions & 0 deletions apps/components-testing-app/src/app/components/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2018-2024 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/

import {Routes} from '@angular/router';

export default [
{
path: 'sci-viewport',
loadChildren: () => import('./sci-viewport/routes'),
},
] satisfies Routes;
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<div class="testee e2e-testee" #testee></div>

<aside>
<section class="e2e-properties">
<header>Properties:</header>
<span>x:</span><input [(ngModel)]="properties.x" class="e2e-x">
<span>y:</span><input [(ngModel)]="properties.y" class="e2e-y">
<span>width:</span><input [(ngModel)]="properties.width" class="e2e-width">
<span>height:</span><input [(ngModel)]="properties.height" class="e2e-height">
<button (click)="applyProperties()" class="apply e2e-apply">Apply</button>
</section>

<section class="e2e-testee-bounding-box">
<header>Testee Bounding Box:</header>
<span>x:</span><span class="e2e-x">{{testeeBoundingBox.x()}}</span>
<span>y:</span><span class="e2e-y">{{testeeBoundingBox.y()}}</span>
<span>width:</span><span class="e2e-width">{{testeeBoundingBox.width()}}</span>
<span>height:</span><span class="e2e-height">{{testeeBoundingBox.height()}}</span>
</section>
</aside>
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
:host {
> div.testee {
position: absolute;
background-color: blue;
}

> aside {
position: fixed;
top: 0;
right: 0;
display: flex;
flex-direction: column;
gap: 2em;
padding: 1em;

> section {
display: grid;
grid-template-columns: auto 1fr;
gap: .25em 1em;

> header {
font-weight: bold;
}

> header, > button.apply {
grid-column: 1/-1;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2018-2024 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/

import {Component, DestroyRef, ElementRef, inject, OnInit, signal, viewChild} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {fromBoundingClientRect$} from '@scion/toolkit/observable';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';

@Component({
selector: 'e2e-bounding-client-rect-page',
templateUrl: './bounding-client-rect-page.component.html',
styleUrl: './bounding-client-rect-page.component.scss',
standalone: true,
imports: [
FormsModule,
],
})
export class BoundingClientRectPageComponent implements OnInit {

private _testee = viewChild.required<ElementRef<HTMLElement>>('testee');
private _destroyRef = inject(DestroyRef);

protected properties = {
x: signal<string>('0px'),
y: signal<string>('0px'),
width: signal<string>('100px'),
height: signal<string>('100px'),
};

protected testeeBoundingBox = {
x: signal<number | undefined>(undefined),
y: signal<number | undefined>(undefined),
width: signal<number | undefined>(undefined),
height: signal<number | undefined>(undefined),
};

public ngOnInit(): void {
this.applyProperties();

fromBoundingClientRect$(this._testee().nativeElement)
.pipe(takeUntilDestroyed(this._destroyRef))
.subscribe(domRect => {
this.testeeBoundingBox.x.set(domRect.x);
this.testeeBoundingBox.y.set(domRect.y);
this.testeeBoundingBox.width.set(domRect.width);
this.testeeBoundingBox.height.set(domRect.height);
});
}

public applyProperties(): void {
this._testee().nativeElement.style.left = this.properties.x();
this._testee().nativeElement.style.top = this.properties.y();
this._testee().nativeElement.style.width = this.properties.width();
this._testee().nativeElement.style.height = this.properties.height();
}
}
16 changes: 16 additions & 0 deletions apps/components-testing-app/src/app/toolkit/observable/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2018-2024 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/

import {Routes} from '@angular/router';
import {BoundingClientRectPageComponent} from './bounding-client-rect/bounding-client-rect-page.component';

export default [
{path: 'bounding-client-rect', component: BoundingClientRectPageComponent},
] satisfies Routes;
17 changes: 17 additions & 0 deletions apps/components-testing-app/src/app/toolkit/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2018-2024 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/

import {Routes} from '@angular/router';

export default [
{
path: 'observable', loadChildren: () => import('./observable/routes'),
},
] satisfies Routes;
2 changes: 1 addition & 1 deletion docs/site/scion-toolkit.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ This module provides framework-agnostic utilities in TypeScript. It only has a d
Provides cryptographic functions.

- [**Observable**][link-tool-observable]\
Provides RxJS Observables for observing the size or DOM mutations of an HTML element.
Provides RxJS observables to observe various aspects of an HTML element, such as size, position, and mutations.

- [**Operators**][link-tool-operators]\
Provides a set of useful RxJS operators.
Expand Down
74 changes: 45 additions & 29 deletions docs/site/tools/observable.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

## [SCION Toolkit][menu-home] > [@scion/toolkit][link-scion-toolkit] > Observable

The NPM sub-module `@scion/toolkit/observable` provides a set of useful RxJS observables.
The NPM sub-module `@scion/toolkit/observable` provides RxJS observables to observe various aspects of an HTML element, such as size, position, and mutations.

To use the observables, install the NPM module `@scion/toolkit` as following:

Expand All @@ -14,68 +14,84 @@ npm install @scion/toolkit
```

<details>
<summary><strong>fromDimension$</strong></summary>

Allows observing the dimension of an element. Upon subscription, it emits the element's dimension, and then continuously emits when the dimension of the element changes. It never completes.
<summary><strong>fromResize$</strong></summary>

```typescript
import {Dimension, fromDimension$} from '@scion/toolkit/observable';
Wraps the native [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) in an RxJS observable to observe resizing of an element.

Upon subscription, emits the current size, and then continuously when the size changes. The observable never completes.

```ts
import {fromResize$} from '@scion/toolkit/observable';

const element: HTMLElement = ...;
fromDimension$(element).subscribe((dimension: Dimension) => {
console.log(dimension);
fromResize$(element).subscribe((entries: ResizeObserverEntry[]) => {

});
```

The Observable uses the native [`ResizeObserver`](https://wicg.github.io/ResizeObserver) to detect size changes of the passed element.

</details>

<details>
<summary><strong>fromMutation$</strong></summary>

Allows watching for changes being made to the DOM tree of an HTML element. It never completes.

The Observable wraps a [`MutationObserver`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) to watch for changes being made to the DOM tree.

```typescript
Wraps the native [`MutationObserver`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) in an RxJS observable to observe mutations of an element.

```ts
import {fromMutation$} from '@scion/toolkit/observable';

const element: HTMLElement = ...;
fromMutation$(element).subscribe((mutations: MutationRecord[]) => {
console.log(mutations);

});
```

When constructing the Observable, you can pass a `MutationObserverInit` options object to control which attributes or events to observe. See https://developer.mozilla.org/en-US/docs/Web/API/MutationObserverInit for more information.
</details>

<details>
<summary><strong>fromIntersection$</strong></summary>

Wraps the native [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) in an RxJS observable to observe intersection of an element.

Upon subscription, emits the current intersection state, and then continuously when the intersection state changes. The observable never completes.

```ts
import {fromIntersection$} from '@scion/toolkit/observable';

const element: HTMLElement = ...;
fromIntersection$(element, {threshold: 1}).subscribe((entries: IntersectionObserverEntry[]) => {

});
```

</details>

<details>
<summary><strong>fromBoundingClientRect$</strong></summary>

Allows observing an element's bounding box, providing information about the element's size and position relative to the browser viewport. Refer to https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect for more information.

Upon subscription, the Observable emits the element's current bounding box, and then continuously emits when its bounding box changes, e.g., due to a change in the layout. The Observable never completes.
Observes changes to the bounding box of an element.

```typescript
The [bounding box](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) includes the element's position relative to the top-left of the viewport and its size.

Upon subscription, emits the current bounding box, and then continuously when the bounding box changes. The observable never completes.


```ts
import {fromBoundingClientRect$} from '@scion/toolkit/observable';

const element: HTMLElement = ...;
fromBoundingClientRect$(element).subscribe((boundingBox: Readonly<DOMRect>) => {
console.log(boundingBox);
fromBoundingClientRect$(element).subscribe((clientRect: DOMRect) => {

});
```

***
If you are only interested in element size changes and not position changes, consider using the `fromDimension$` Observable as it is more efficient because natively supported by the browser.
The target element and the document root (`<html>`) must be positioned (`relative`, `absolute`, or `fixed`). If not, positioning is changed to `relative`.
***

*Note on the detection of position changes:*\

There is, unfortunately, no native browser API to detect position changes of an element in a performant and reliable way. Our approach to detecting position changes of an element is based on the premise that it usually involves a parent or a parent's direct child changing in size. Repositioning can further occur when the user scrolls a parent container or when elements are added to or removed from the DOM. This covers most cases, but not all.

We are aware that this approach can be quite expensive, mainly because potentially a large number of elements need to be monitored for resizing/scrolling. Therefore, use this Observable only if you need to be informed about position changes. For pure dimension changes use the `fromDimension$` Observable instead.
*Note:*
As of 2024, there is no native browser API to observe the position of an element. This implementation uses
[`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) and [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) to detect position changes.
For tracking only size changes, use `fromResize$` instead.
</details>

[menu-home]: /README.md
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
*/

import {Locator, Page} from '@playwright/test';
import {isActiveElement} from '../../helper/testing.utils';
import {isActiveElement} from '../../../helper/testing.utils';

const PATH = '/#/sci-viewport/focus';
const PATH = '/#/components/sci-viewport/focus';

export class ViewportFocusPagePO {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* SPDX-License-Identifier: EPL-2.0
*/

import {test} from '../../fixtures';
import {test} from '../../../fixtures';
import {expect} from '@playwright/test';
import {ViewportFocusPagePO} from './viewport-focus-page.po';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import {Locator, Page} from '@playwright/test';

const PATH = '/#/sci-viewport/hover';
const PATH = '/#/components/sci-viewport/hover';

export class ViewportHoverPagePO {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* SPDX-License-Identifier: EPL-2.0
*/

import {test} from '../../fixtures';
import {test} from '../../../fixtures';
import {expect} from '@playwright/test';
import {ViewportHoverPagePO} from './viewport-hover-page.po';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import {Locator, Page} from '@playwright/test';

const PATH = '/#/sci-viewport/overlap';
const PATH = '/#/components/sci-viewport/overlap';

export class ViewportOverlapPagePO {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* SPDX-License-Identifier: EPL-2.0
*/

import {test} from '../../fixtures';
import {test} from '../../../fixtures';
import {expect} from '@playwright/test';
import {ViewportOverlapPagePO} from './viewport-overlap-page.po';

Expand Down
Loading

0 comments on commit 9eea1a3

Please sign in to comment.