diff --git a/docs/src/content/docs/utilities/Effects-Side-Effects/explicit-after-render-effect.md b/docs/src/content/docs/utilities/Effects-Side-Effects/explicit-after-render-effect.md new file mode 100644 index 00000000..37d55557 --- /dev/null +++ b/docs/src/content/docs/utilities/Effects-Side-Effects/explicit-after-render-effect.md @@ -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()); +}); +``` diff --git a/libs/ngxtension/explicit-after-render-effect/README.md b/libs/ngxtension/explicit-after-render-effect/README.md new file mode 100644 index 00000000..fefa4c34 --- /dev/null +++ b/libs/ngxtension/explicit-after-render-effect/README.md @@ -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`. diff --git a/libs/ngxtension/explicit-after-render-effect/ng-package.json b/libs/ngxtension/explicit-after-render-effect/ng-package.json new file mode 100644 index 00000000..b3e53d69 --- /dev/null +++ b/libs/ngxtension/explicit-after-render-effect/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/ngxtension/explicit-after-render-effect/project.json b/libs/ngxtension/explicit-after-render-effect/project.json new file mode 100644 index 00000000..e251aabf --- /dev/null +++ b/libs/ngxtension/explicit-after-render-effect/project.json @@ -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}"] + } + } +} diff --git a/libs/ngxtension/explicit-after-render-effect/src/explicit-after-render-effect.spec.ts b/libs/ngxtension/explicit-after-render-effect/src/explicit-after-render-effect.spec.ts new file mode 100644 index 00000000..c0367ebf --- /dev/null +++ b/libs/ngxtension/explicit-after-render-effect/src/explicit-after-render-effect.spec.ts @@ -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]); + }); +}); diff --git a/libs/ngxtension/explicit-after-render-effect/src/explicit-after-render-effect.ts b/libs/ngxtension/explicit-after-render-effect/src/explicit-after-render-effect.ts new file mode 100644 index 00000000..d99ea25a --- /dev/null +++ b/libs/ngxtension/explicit-after-render-effect/src/explicit-after-render-effect.ts @@ -0,0 +1,169 @@ +import { + afterRenderEffect, + AfterRenderOptions, + AfterRenderRef, + EffectCleanupRegisterFn, + Signal, + untracked, +} from '@angular/core'; + +/** Getters used to declare explicit dependencies. */ +type ExplicitAfterRenderEffectValues = { + readonly [K in keyof T]: () => T[K]; +}; + +/** Wrapper for 'earlyRead': resolves deps and runs callback in untracked. */ +function wrapEarlyReadPhase( + deps: ReadonlyArray<() => unknown>, + fn: (values: Input, onCleanup: EffectCleanupRegisterFn) => R, +): (onCleanup: EffectCleanupRegisterFn) => R { + return (onCleanup) => { + const values = deps.map((d) => d()) as unknown as Input; + return untracked(() => fn(values, onCleanup)); + }; +} + +/** Wrapper for other phases: handles dynamic args (prev/cleanup) and untracked. */ +function wrapPhase( + deps: ReadonlyArray<() => unknown>, + fn: ( + values: Input, + prev: Signal

| undefined, + onCleanup: EffectCleanupRegisterFn, + ) => R, +): ( + prevOrCleanup: Signal

| EffectCleanupRegisterFn, + maybeCleanup?: EffectCleanupRegisterFn, +) => R { + return (prevOrCleanup, maybeCleanup): R => { + const values = deps.map((d) => d()) as unknown as Input; + const onCleanup = (maybeCleanup ?? + prevOrCleanup) as EffectCleanupRegisterFn; + const prev = maybeCleanup ? (prevOrCleanup as Signal

) : undefined; + return untracked(() => fn(values, prev, onCleanup)); + }; +} + +/** + * Explicit version of afterRenderEffect: triggers only when 'deps' change. + * Internal signal reads are ignored. + * @example + * Single-phase (convenience) form, runs in the `read` phase: + * ```typescript + * import { explicitAfterRenderEffect } from 'ngxtension/explicit-after-render-effect'; + * + * const width = signal(0); + * + * explicitAfterRenderEffect([width], ([width], cleanup) => { + * console.log('measured width', width); + * cleanup(() => console.log('cleanup')); + * }); + * ``` + * + * @example + * Multi-phase (spec) form. Each phase receives the resolved deps as its first argument + * and the previous phase's signal as the second: + * ```typescript + * 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()), + * }, + * ); + * ``` + * + * @param deps - Tuple of signals or signal-reading functions that the effect depends on + * @param fnOrSpec - Either a single callback (run in the `read` phase) or a spec object with `earlyRead` / `write` / `mixedReadWrite` / `read` phases + * @param options - Forwarded to `afterRenderEffect` + */ +export function explicitAfterRenderEffect( + deps: ExplicitAfterRenderEffectValues, + fn: (deps: Input, onCleanup: EffectCleanupRegisterFn) => void, + options?: AfterRenderOptions, +): AfterRenderRef; + +export function explicitAfterRenderEffect< + Input extends readonly unknown[], + E = never, + W = never, + M = never, +>( + deps: ExplicitAfterRenderEffectValues, + spec: { + earlyRead?: (deps: Input, onCleanup: EffectCleanupRegisterFn) => E; + write?: ( + deps: Input, + prev: Signal | undefined, + onCleanup: EffectCleanupRegisterFn, + ) => W; + mixedReadWrite?: ( + deps: Input, + prev: Signal | undefined, + onCleanup: EffectCleanupRegisterFn, + ) => M; + read?: ( + deps: Input, + prev: Signal | undefined, + onCleanup: EffectCleanupRegisterFn, + ) => void; + }, + options?: AfterRenderOptions, +): AfterRenderRef; + +export function explicitAfterRenderEffect( + deps: ReadonlyArray<() => unknown>, + fnOrSpec: + | ((values: readonly unknown[], onCleanup: EffectCleanupRegisterFn) => void) + | { + earlyRead?: ( + values: readonly unknown[], + onCleanup: EffectCleanupRegisterFn, + ) => unknown; + write?: ( + values: readonly unknown[], + prev: Signal | undefined, + onCleanup: EffectCleanupRegisterFn, + ) => unknown; + mixedReadWrite?: ( + values: readonly unknown[], + prev: Signal | undefined, + onCleanup: EffectCleanupRegisterFn, + ) => unknown; + read?: ( + values: readonly unknown[], + prev: Signal | undefined, + onCleanup: EffectCleanupRegisterFn, + ) => void; + }, + options?: AfterRenderOptions, +): AfterRenderRef { + if (typeof fnOrSpec === 'function') { + return afterRenderEffect( + { + read: wrapPhase(deps, (values, _prev, onCleanup) => + fnOrSpec(values, onCleanup), + ), + }, + options, + ); + } + + return afterRenderEffect( + { + earlyRead: fnOrSpec.earlyRead + ? wrapEarlyReadPhase(deps, fnOrSpec.earlyRead) + : undefined, + write: fnOrSpec.write ? wrapPhase(deps, fnOrSpec.write) : undefined, + mixedReadWrite: fnOrSpec.mixedReadWrite + ? wrapPhase(deps, fnOrSpec.mixedReadWrite) + : undefined, + read: fnOrSpec.read ? wrapPhase(deps, fnOrSpec.read) : undefined, + }, + options, + ); +} diff --git a/libs/ngxtension/explicit-after-render-effect/src/index.ts b/libs/ngxtension/explicit-after-render-effect/src/index.ts new file mode 100644 index 00000000..1f10e7da --- /dev/null +++ b/libs/ngxtension/explicit-after-render-effect/src/index.ts @@ -0,0 +1 @@ +export * from './explicit-after-render-effect';