Intersection events and loose ends
Tags: angular, webdev, tutorial, typescript
Published: 2023-08-15 06:13:37

We used intersection observer to add classes and lazy load images. Here are some final touches that may enhance those features.

### Add output events

We already have a good directive to add classes, and if no classes are passed it still acts well. Let's add a couple of events to further its use.

```ts
// inview.directive
// Enhance with output events

@Output() onInview = new EventEmitter<void>();
@Output() onOutofview = new EventEmitter<void>();

// then emit in the proper places
private classChange(
   //...
  ) {
    const c = this.viewClasses;

    if (entry.isIntersecting) {
	    //...
      this.onInview.emit();

      if (this.options.once) {
        observer.disconnect();
      }

    } else {
      //...
      this.onOutofview.emit();

    }
  }
)
```

It can be used as following

```html
<span crInview onIniew="callMeInView()" onOutofview="callMeOutofView()">something</span>
```

### Enhancement 2: Exposure

Another enhancement we can afford is to expose the `observe` and `unobserve` functions. For this to work we need to promote a local member for the intersection observer (`io`).

```ts
// add local member
private io: IntersectionObserver;

// set and use this.io instead of observer

// add a couple of functions

observe() {
  this.io.observe(this.el.nativeElement);
}
unobserve() {
  this.io.unobserve(this.el.nativeElement);
}

// inside class change, we gotta swap disconnect with unobserve
private classChange(...){
  // ...
  if (this.options.once) {
    this.unobserve();
  }
}
```

Note: I have used `entry.target` interchangeably with `this.el.nativeElement`, truth is, since we have a **single** observer per directive, they are the same, we only need `entry` for the extra properties provided like `isIntersecting`.

We need to export the directive to be able to use those methods.

```ts
// inview.directive

@Directive({
  selector: '[crLazy]',
  standalone: true,
  // export to use
  exportAs: 'crLazy',
})
```

To use, in our template:

```html
<!--a box that is being observed-->
 <div
  crInview
  #bluebox="crInview"
>
  ...content
</div>

 Stop observing the box below, then start again <br />
<button (click)="bluebox.unobserve()">Stop observing</button>
<button (click)="bluebox.observe()">Start observing</button>
```

This looks thorough. Too thorough. I might never use this feature in my whole life. We'll stop here before it gets too slimy.

### Bug: dynamic images change undetected

When an image's source changes dynamically, we need a way to restart observation. Bummer. Let's fix that. There are two ways to do that, one is the `setter` of the main string (`crLazy`). This involves another `private` variable to keep track of, but I am not going into that direction when I have `OnChanges` lifecycle hook.

```ts
// lazy.directive
export class LazyDirective implements AfterViewInit, OnChanges {

  // add OnChanges event handler
  ngOnChanges(c: SimpleChanges) {

    if (c.crLazy.firstChange) {
      // act normally
      return;
    }

    if (c.crLazy.currentValue !== c.crLazy.previousValue) {
      // start observing again
      this.io.observe(this.el.nativeElement);
    }
  }
}
```

For this to work, we need to promote the intersection observer to be a private member (`io`), and we can only use `unobserve`, and never `disconnect`.

```ts
// lazy.directive

// promote the io
private io: IntersectionObserver;

// then use this.io intead
ngAfterViewInit() {

 this.io = new IntersectionObserver(
   // ...
 );
 this.io.observe(this.el.nativeElement);
}

// then ngOnchanges
```

In [StackBlitz lazy component](https://stackblitz.com/edit/angular-ui-directive?file=src%2Fapp%2Fcomponents%2Flazy.component.html), test that by clicking on the "change image" button. The image should change.

We could have also exposed the `observe` and `unobserve` methods, and let the consuming component handle it, or added a live `option` for some images. But that is not always practical. The image could be sitting in a highly reusable component like a product card.

#### Bonus: fade in effect

Here is a nice effect to add to let the background image fade in when ready. This effect is 100% external to the directive. This is how we know we built a flexible directive.

```css
/*add style to fade in an image when ready*/
.hero {
  background: no-repeat center center;
  background-size: cover;
  background-color: rgba(0, 0, 0, 0.35);
  background-blend-mode: overlay;
  transition: background-color 0.5s ease-in-out;

  /*unessential*/
  color: #fff;
  min-height: 40dvh;
  display: flex;
  align-items: center;
  justify-content: center;
  /* image set by code */
}
.hero-null {
  background-color: black;
}
```

Then just use the directive with `null` fall back

```html
<div
  class="hero"
  crLazy="largeimage.png"
  [options]="{ nullCss: 'hero-null' }"
>
Text on image
</div>
```

Have a look in [StackBlitz lazy component](https://stackblitz.com/edit/angular-ui-directive?file=src%2Fapp%2Fcomponents%2Flazy.component.html).

### Enhancement #3: Destroy

There is one cheap enhancement we should have thought about earlier, and that is to dispose the observer when the host element is destroyed.

```
// add onDestroy event handler for both directives
export class LazyDirective implements AfterViewInit, OnChanges, OnDestroy {
  // ...
   ngOnDestroy() {
    this.io?.disconnect();
    this.io = null;
  }
}
```

### Revisit a single observer

I'm not quite keen on creating solutions for probable problems, but having a **single intersection observer** per page is still eating at me. Placing the observer on the window level and passing the arguments in the target was okay. But I wanted to investigate a different approach, where we are aware of the observers. Where better to do that than a **root service**?

```ts
// experimental lazy/service

@Injectable({ providedIn: 'root' })
export class LazyService {
  obs: { [key: string]: IntersectionObserver } = {};

  newOb(cb: Function, id: string, threshold: number = 0) {
    // create a new observer, if no id is passed, consider as unique

    if (id && this.obs[id]) {
      return this.obs[id];
    }
    const io = new IntersectionObserver(
      (entries, observer) => {
        entries.forEach((entry) => cb(entry));
      },
      {
        threshold: threshold,
      }
    );
    // save observer to reuse
    if (id) {
      this.obs[id] = io;
    }
    return io;
  }
}
```

To use that, we only need to do the following

-   Add `id` to `options`
-   If `id` was passed, we return an existing `observer` previously created, or create new one and save it. Else we return the new `observer` without saving it.
-   We need to make sure the `nativeElement` is not used in the `callback`, thus the image source passed, must be saved as an `attribute` of the element, so that we can recall it safely upon intersection.
-   We also need to set `options` as a property of the element (cannot set attribute to a `json` object).
-   With this approach, we cannot `disconnect`. But since the `observer` is saved on **root**, it is **reused across multiple routes**

```ts
// expiremental lazy/directive

// change this to have the target explicitly passed
private setImage(src: string, target: HTMLElement) {
  if (target.tagName === 'IMG') {
    this.renderer.setAttribute(target, 'src', src);
  } else {
    this.renderer.setAttribute(target, 'style', `background-image: url(${src})`);
  }
}

// change this to pass the target
private lazyLoad(entry: IntersectionObserverEntry) {
  // ...
   if (entry.isIntersecting) {
    // if IMG, change src
    const img = new Image();
    // get options saved
    const options = entry.target['options'];

    img.addEventListener('load', () => {
      // pass target explicitly
      this.setImage(img.src, <HTMLElement>entry.target);
      // use options instead of this.options
      this.renderer.removeClass(entry.target, options.nullCss);
      // disconnect
      this.io.unobserve(entry.target);
    });
    if (options.fallBack) {
      img.addEventListener('error', () => {
        this.setImage(options.fallBack, <HTMLElement>entry.target);
        // unobserve
        this.io.unobserve(entry.target);
      });
    }
    // get the source from attribute
    img.src = entry.target.getAttribute('shLazy');
  }
}

// then change this to call the service
ngAfterViewInit() {
	// ...
	// this can have the nativeElement
	this.setImage(this.options.initial, this.el.nativeElement);

	// ...
	// save the content in an attribute to retrieve when wanted
  this.el.nativeElement.setAttribute('shLazy', this.shLazy);
  // also save options
  this.renderer.setProperty(this.el.nativeElement, 'options', this.options);

	this.io = this.lazyService.newOb((entry) => {
      this.lazyLoad(entry);
    }, this.options.id, this.options.threshold);

    this.io.observe(this.el.nativeElement);
}
ngOnChanges(c: SimpleChanges) {
  if (c.shLazy.firstChange) {
    return;
  }
  if (c.shLazy.currentValue !== c.shLazy.previousValue) {
    // set attribute again
    this.el.nativeElement.setAttribute('shLazy', c.shLazy.currentValue);
    // observe element
    this.io.observe(this.el.nativeElement);
  }
}
```

To use, we only need to pass a unique id for all elements we need to observe together. The downside to this, is that the `threshold` is no longer unique to every element, but rather to every group of elements. The first host element to initialize the `observer`, is the deciding element.

```ts
<img [src]="defaultImage" [shLazy]="image" [options]="{fallBack: defaultImage, id: 'productcard'}"  />
```

This solution does not look neat, nor does it have a big effect on performance. But if your page has like a 1000 images above the fold, and more 1000s down the fold, you might want to consider a single observer.

Find this code in [StackBlitz lazy folder](https://stackblitz.com/edit/angular-ui-directive?file=src%2Fapp%2Flazy%2Fdirective.ts,src%2Fapp%2Flazy%2Fservice.ts).

That's it for our intersection observation. Thank you for reading this far. Did you confuse the bug for a cockroach?


### RESOURCES

-   [StackBlitz project](https://stackblitz.com/edit/angular-ui-directive)