Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
title: explicitAfterRenderEffect
description: ngxtension/explicit-after-render-effect
entryPoint: ngxtension/explicit-after-render-effect
badge: stable
contributors: ['jonata-biondi']
---

`explicitAfterRenderEffect` is the render-phase counterpart of [`explicitEffect`](./explicit-effect). It wraps Angular's `afterRenderEffect` so the effect re-runs only when the signals (or signal-reading functions) listed in the deps array change — any other signal read inside the body is ignored.

This is useful when you reach for `afterRenderEffect` to do DOM measurement or layout writes and don't want every transitive signal read in the callback to retrigger a render-phase callback.

```ts
import { explicitAfterRenderEffect } from 'ngxtension/explicit-after-render-effect';
```

## Usage

The single-callback form is the convenience overload. The callback runs in the `read` phase and re-runs only when one of the listed deps changes.

```ts
const width = signal(0);

explicitAfterRenderEffect([width], ([width]) => {
console.log('width changed to', width);
});
```

The deps array accepts:

- Signals (also computed signals)
- Writable signals
- Functions that read signals (e.g. `() => this.count()`)

```ts
const count = signal(0);
const state = signal('idle');
const sum = () => count() * 2;

explicitAfterRenderEffect([count, state, sum], ([count, state, sum]) => {
console.log({ count, state, sum });
});
```

## Phases

`afterRenderEffect` exposes four phases — `earlyRead`, `write`, `mixedReadWrite`, `read` — that run in that fixed order. Pass an object instead of a single function to opt into the multi-phase form. Each phase receives the resolved deps as its first argument and a `Signal` of the previous phase's return value as its second argument.

```ts
explicitAfterRenderEffect([el, width], {
earlyRead: ([el]) => el.getBoundingClientRect().height,
write: ([el, width], prevHeight) => {
el.style.width = `${width}px`;
return prevHeight?.();
},
read: ([el]) => {
console.log('final size', el.getBoundingClientRect());
},
});
```

You can omit any phase you don't need.

## Cleanup

Each phase callback receives an `onCleanup` argument as its last parameter, called before the next run and on destroy.

```ts
const visible = signal(true);

explicitAfterRenderEffect([visible], ([visible], cleanup) => {
const observer = new ResizeObserver(() => {
/* ... */
});
cleanup(() => observer.disconnect());
});
```
3 changes: 3 additions & 0 deletions libs/ngxtension/explicit-after-render-effect/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# ngxtension/explicit-after-render-effect

Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/explicit-after-render-effect`.
5 changes: 5 additions & 0 deletions libs/ngxtension/explicit-after-render-effect/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"lib": {
"entryFile": "src/index.ts"
}
}
20 changes: 20 additions & 0 deletions libs/ngxtension/explicit-after-render-effect/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "ngxtension/explicit-after-render-effect",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"sourceRoot": "libs/ngxtension/explicit-after-render-effect/src",
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/ngxtension/jest.config.ts",
"testPathPattern": ["explicit-after-render-effect"]
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"]
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { explicitAfterRenderEffect } from './explicit-after-render-effect';

describe(explicitAfterRenderEffect.name, () => {
describe('convenience (single-phase) overload', () => {
@Component({ standalone: true, template: '' })
class Host {
tracked = signal(0);
untracked = signal('idle');

runs: Array<{ tracked: number; untracked: string }> = [];
cleanups = 0;

ref = explicitAfterRenderEffect([this.tracked], ([tracked], cleanup) => {
this.runs.push({ tracked, untracked: this.untracked() });
cleanup(() => {
this.cleanups += 1;
});
});
}

it('runs after first render and re-runs only when listed deps change', () => {
const fixture = TestBed.createComponent(Host);
fixture.autoDetectChanges();
const c = fixture.componentInstance;

expect(c.runs.length).toBe(1);
expect(c.runs[0]).toEqual({ tracked: 0, untracked: 'idle' });
expect(c.cleanups).toBe(0);

c.tracked.set(1);
fixture.detectChanges();
expect(c.runs.length).toBe(2);
expect(c.runs[1].tracked).toBe(1);
expect(c.cleanups).toBe(1);

c.untracked.set('busy');
fixture.detectChanges();
expect(c.runs.length).toBe(2);
expect(c.cleanups).toBe(1);
});

it('stops running after destroy()', () => {
const fixture = TestBed.createComponent(Host);
fixture.autoDetectChanges();
const c = fixture.componentInstance;
expect(c.runs.length).toBe(1);

c.ref.destroy();

c.tracked.set(42);
fixture.detectChanges();
expect(c.runs.length).toBe(1);
});

it('forwards an onCleanup callback to the user function', () => {
@Component({ standalone: true, template: '' })
class CleanupHost {
input = signal(0);
registered = 0;

cleanupCalls = 0;

ref = explicitAfterRenderEffect([this.input], (_deps, cleanup) => {
expect(typeof cleanup).toBe('function');
cleanup(() => {
this.cleanupCalls += 1;
});
this.registered += 1;
});
}

const fixture = TestBed.createComponent(CleanupHost);
fixture.autoDetectChanges();
expect(fixture.componentInstance.registered).toBe(1);
});
});

describe('spec (multi-phase) overload', () => {
it('forwards resolved deps to each phase and chains prev between phases', () => {
@Component({ standalone: true, template: '' })
class Host {
input = signal(10);

phaseLog: string[] = [];
earlyReadSawDeps: number | undefined;
writeSawPrev: number | undefined;
mixedReadWriteSawPrev: number | undefined;
readSawPrev: number | undefined;

ref = explicitAfterRenderEffect([this.input], {
earlyRead: ([input]) => {
this.phaseLog.push('earlyRead');
this.earlyReadSawDeps = input;
return input + 1;
},
write: (_deps, prev) => {
this.phaseLog.push('write');
this.writeSawPrev = prev?.();
return (prev?.() ?? 0) + 1;
},
mixedReadWrite: (_deps, prev) => {
this.phaseLog.push('mixedReadWrite');
this.mixedReadWriteSawPrev = prev?.();
return (prev?.() ?? 0) + 1;
},
read: (_deps, prev) => {
this.phaseLog.push('read');
this.readSawPrev = prev?.();
},
});
}

const fixture = TestBed.createComponent(Host);
fixture.autoDetectChanges();
const c = fixture.componentInstance;

expect(c.phaseLog).toEqual([
'earlyRead',
'write',
'mixedReadWrite',
'read',
]);
expect(c.earlyReadSawDeps).toBe(10);
expect(c.writeSawPrev).toBe(11);
expect(c.mixedReadWriteSawPrev).toBe(12);
expect(c.readSawPrev).toBe(13);
});

it('omitted phases are not registered', () => {
@Component({ standalone: true, template: '' })
class Host {
input = signal(0);
readRuns = 0;

ref = explicitAfterRenderEffect([this.input], {
read: () => {
this.readRuns += 1;
},
});
}

const fixture = TestBed.createComponent(Host);
fixture.autoDetectChanges();
const c = fixture.componentInstance;

expect(c.readRuns).toBe(1);

c.input.set(1);
fixture.detectChanges();
expect(c.readRuns).toBe(2);
});
});

it('accepts signal-reading functions in the deps tuple', () => {
@Component({ standalone: true, template: '' })
class Host {
a = signal(1);
b = signal(2);
runs: number[] = [];

ref = explicitAfterRenderEffect([() => this.a() + this.b()], ([sum]) => {
this.runs.push(sum);
});
}

const fixture = TestBed.createComponent(Host);
fixture.autoDetectChanges();
const c = fixture.componentInstance;

expect(c.runs).toEqual([3]);

c.a.set(5);
fixture.detectChanges();
expect(c.runs).toEqual([3, 7]);
});
});
Loading
Loading