From 12c1bd9b6421fc01234e14236557f0dc400393f0 Mon Sep 17 00:00:00 2001 From: Xavier Dupessey Date: Thu, 30 Jul 2026 14:06:11 +0200 Subject: [PATCH] feat(effect): consolidate effect utilities into one --- .../effect-once-if/src/effect-once-if.spec.ts | 11 +- libs/ngxtension/effect/README.md | 3 + libs/ngxtension/effect/ng-package.json | 5 + libs/ngxtension/effect/project.json | 20 ++ libs/ngxtension/effect/src/effect.spec.ts | 268 ++++++++++++++++++ libs/ngxtension/effect/src/effect.ts | 194 +++++++++++++ libs/ngxtension/effect/src/index.ts | 1 + .../src/explicit-effect.spec.ts | 18 +- libs/ngxtension/reactive-on/src/on.spec.ts | 64 ++--- tsconfig.base.json | 1 + 10 files changed, 539 insertions(+), 46 deletions(-) create mode 100644 libs/ngxtension/effect/README.md create mode 100644 libs/ngxtension/effect/ng-package.json create mode 100644 libs/ngxtension/effect/project.json create mode 100644 libs/ngxtension/effect/src/effect.spec.ts create mode 100644 libs/ngxtension/effect/src/effect.ts create mode 100644 libs/ngxtension/effect/src/index.ts diff --git a/libs/ngxtension/effect-once-if/src/effect-once-if.spec.ts b/libs/ngxtension/effect-once-if/src/effect-once-if.spec.ts index 9af97d03a..5d8c7e1f9 100644 --- a/libs/ngxtension/effect-once-if/src/effect-once-if.spec.ts +++ b/libs/ngxtension/effect-once-if/src/effect-once-if.spec.ts @@ -1,6 +1,6 @@ import { Component, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { effectOnceIf } from './effect-once-if'; +import { effect } from 'ngxtension/effect'; function createTestComponent(triggerValue: number) { const log: string[] = []; @@ -10,9 +10,10 @@ function createTestComponent(triggerValue: number) { class Example { count = signal(0); - ref = effectOnceIf( - () => this.count() === triggerValue, - (value, onCleanup) => { + ref = effect( + [() => this.count() === triggerValue], + { once: true, filter: Boolean }, + ([value], _previousValues, onCleanup) => { log.push(`received ${triggerValue}: ${value}`); onCleanup(() => { logCleanup.push(`cleaning effect with condition ${triggerValue}`); @@ -24,7 +25,7 @@ function createTestComponent(triggerValue: number) { return { component: Example, log, logCleanup }; } -describe(effectOnceIf.name, () => { +describe('effectOnceIf behavior with effect', () => { it('should run effect once and cleanup', () => { const test = createTestComponent(2); const fixture = TestBed.createComponent(test.component); diff --git a/libs/ngxtension/effect/README.md b/libs/ngxtension/effect/README.md new file mode 100644 index 000000000..4691b6991 --- /dev/null +++ b/libs/ngxtension/effect/README.md @@ -0,0 +1,3 @@ +# ngxtension/effect + +Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/effect`. diff --git a/libs/ngxtension/effect/ng-package.json b/libs/ngxtension/effect/ng-package.json new file mode 100644 index 000000000..b3e53d699 --- /dev/null +++ b/libs/ngxtension/effect/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/ngxtension/effect/project.json b/libs/ngxtension/effect/project.json new file mode 100644 index 000000000..b1de62eb1 --- /dev/null +++ b/libs/ngxtension/effect/project.json @@ -0,0 +1,20 @@ +{ + "name": "ngxtension/effect", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/ngxtension/effect/src", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/ngxtension/jest.config.ts", + "testPathPattern": ["effect"] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + } + } +} diff --git a/libs/ngxtension/effect/src/effect.spec.ts b/libs/ngxtension/effect/src/effect.spec.ts new file mode 100644 index 000000000..127311e4c --- /dev/null +++ b/libs/ngxtension/effect/src/effect.spec.ts @@ -0,0 +1,268 @@ +import { ApplicationRef, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { effect, nullishValues } from './effect'; + +describe('effect', () => { + let appRef: ApplicationRef; + + beforeEach(() => { + TestBed.configureTestingModule({}); + appRef = TestBed.inject(ApplicationRef); + }); + + it('skips the initial trigger and passes current and previous dependency values', () => { + const first = signal(1); + const second = signal('a'); + const callback = jest.fn(); + + TestBed.runInInjectionContext(() => { + effect([first, second], { defer: true }, callback); + }); + appRef.tick(); + + expect(callback).not.toHaveBeenCalled(); + + first.set(2); + appRef.tick(); + + expect(callback).toHaveBeenNthCalledWith( + 1, + [2, 'a'], + [undefined, undefined], + expect.any(Function), + undefined, + ); + + second.set('b'); + appRef.tick(); + + expect(callback).toHaveBeenNthCalledWith( + 2, + [2, 'b'], + [2, 'a'], + expect.any(Function), + undefined, + ); + }); + + it('skips callback execution when any dependency value is nullish', () => { + const first = signal(1); + const second = signal('a'); + const callback = jest.fn(); + + TestBed.runInInjectionContext(() => { + effect([first, second], { filter: nullishValues }, callback); + }); + appRef.tick(); + + expect(callback).toHaveBeenNthCalledWith( + 1, + [1, 'a'], + [undefined, undefined], + expect.any(Function), + undefined, + ); + + first.set(null); + appRef.tick(); + + second.set(undefined); + appRef.tick(); + + first.set(2); + appRef.tick(); + + expect(callback).toHaveBeenCalledTimes(1); + + second.set('b'); + appRef.tick(); + + expect(callback).toHaveBeenNthCalledWith( + 2, + [2, 'b'], + [1, 'a'], + expect.any(Function), + undefined, + ); + }); + + it('executes the callback only once after skipped runs', () => { + const dependency = signal(null); + const callback = jest.fn(); + + TestBed.runInInjectionContext(() => { + effect([dependency], { once: true, filter: nullishValues }, callback); + }); + appRef.tick(); + + expect(callback).not.toHaveBeenCalled(); + + dependency.set(1); + appRef.tick(); + + expect(callback).toHaveBeenNthCalledWith( + 1, + [1], + [undefined], + expect.any(Function), + undefined, + ); + + dependency.set(2); + appRef.tick(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('can execute only once on the initial trigger', () => { + const dependency = signal(1); + const callback = jest.fn(); + + TestBed.runInInjectionContext(() => { + effect([dependency], { once: true }, callback); + }); + appRef.tick(); + + expect(callback).toHaveBeenNthCalledWith( + 1, + [1], + [undefined], + expect.any(Function), + undefined, + ); + + dependency.set(2); + appRef.tick(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('executes only once when the filtered dependency is available', () => { + const dependency = signal(0); + const callback = jest.fn(); + + TestBed.runInInjectionContext(() => { + effect( + [() => (dependency() === 2 ? 'ready' : null)], + { once: true, filter: nullishValues }, + callback, + ); + }); + appRef.tick(); + + expect(callback).not.toHaveBeenCalled(); + + dependency.set(1); + appRef.tick(); + + expect(callback).not.toHaveBeenCalled(); + + dependency.set(2); + appRef.tick(); + + expect(callback).toHaveBeenNthCalledWith( + 1, + ['ready'], + [undefined], + expect.any(Function), + undefined, + ); + + dependency.set(3); + appRef.tick(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('can gate explicit dependencies with a filter', () => { + const dependency = signal('idle'); + const ready = signal(false); + const callback = jest.fn(); + + TestBed.runInInjectionContext(() => { + effect([dependency], { once: true, filter: () => ready() }, callback); + }); + appRef.tick(); + + dependency.set('waiting'); + appRef.tick(); + + expect(callback).not.toHaveBeenCalled(); + + ready.set(true); + appRef.tick(); + + expect(callback).toHaveBeenNthCalledWith( + 1, + ['waiting'], + [undefined], + expect.any(Function), + undefined, + ); + + dependency.set('done'); + appRef.tick(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('passes the previous callback return value', () => { + const dependency = signal(1); + const log: number[] = []; + + TestBed.runInInjectionContext(() => { + effect( + [dependency], + ( + [value], + _prevDepValues, + _onCleanup, + prevReturnValue: number | undefined, + ) => { + const result = value + (prevReturnValue ?? 0); + log.push(result); + return result; + }, + ); + }); + appRef.tick(); + + expect(log).toEqual([1]); + + dependency.set(2); + appRef.tick(); + + expect(log).toEqual([1, 3]); + + dependency.set(3); + appRef.tick(); + + expect(log).toEqual([1, 3, 6]); + }); + + it('runs the callback in an untracked context', () => { + const dependency = signal(0); + const incidental = signal('a'); + const callback = jest.fn(() => incidental()); + + TestBed.runInInjectionContext(() => { + effect([dependency], { defer: true }, callback); + }); + appRef.tick(); + + dependency.set(1); + appRef.tick(); + + expect(callback).toHaveBeenCalledTimes(1); + + incidental.set('b'); + appRef.tick(); + + expect(callback).toHaveBeenCalledTimes(1); + + dependency.set(2); + appRef.tick(); + + expect(callback).toHaveBeenCalledTimes(2); + }); +}); diff --git a/libs/ngxtension/effect/src/effect.ts b/libs/ngxtension/effect/src/effect.ts new file mode 100644 index 000000000..292c99d29 --- /dev/null +++ b/libs/ngxtension/effect/src/effect.ts @@ -0,0 +1,194 @@ +import { + effect as angularEffect, + type CreateEffectOptions, + type EffectCleanupRegisterFn, + type EffectRef, + untracked, +} from '@angular/core'; + +export type EffectDependency = () => Value; + +type DependencyValue = + Dependency extends EffectDependency ? Value : never; + +type DependencyValues = { + -readonly [Index in keyof Dependencies]: DependencyValue; +}; + +type PreviousValues = { + -readonly [Index in keyof Values]: Values[Index] | undefined; +}; + +type NonNullishValues = { + -readonly [Index in keyof Values]: NonNullable; +}; + +const isNullish = (value: unknown): value is null | undefined => + value === null || value === undefined; + +export const nullishValues = ( + values: Values, +): values is NonNullishValues => !values.some(isNullish); + +type EffectCallback = ( + values: Values, + previousValues: PreviousValues, + onCleanup: EffectCleanupRegisterFn, + previousReturnValue: ReturnValue | undefined, +) => ReturnValue; + +type EffectPredicate = ( + values: DependencyValues, + previousValues: PreviousValues>, +) => boolean; + +type EffectFilter< + Dependencies extends readonly EffectDependency[], + FilteredValues extends DependencyValues, +> = ( + values: DependencyValues, + previousValues: PreviousValues>, +) => values is FilteredValues; + +interface EffectBehaviorOptions< + Dependencies extends + readonly EffectDependency[] = readonly EffectDependency[], +> { + /** + * Skip the initial effect run and execute the callback only after a dependency changes. + */ + defer?: boolean; + + /** + * Skip callback execution while the predicate returns false. + */ + filter?: EffectPredicate; + + /** + * Destroy the effect after the first callback execution. + */ + once?: boolean; +} + +type FilteredEffectBehaviorOptions< + Dependencies extends readonly EffectDependency[], + FilteredValues extends DependencyValues, +> = Omit, 'filter'> & { + filter: EffectFilter; +}; + +type RuntimeValues = DependencyValues; +type RuntimeEffectCallback = ( + values: readonly unknown[], + previousValues: PreviousValues, + onCleanup: EffectCleanupRegisterFn, + previousReturnValue: unknown, +) => unknown; +type AnyEffectCallback = EffectCallback; + +type EffectArgumentsWithBehavior = [ + behaviorOptions: EffectBehaviorOptions, + callback: AnyEffectCallback, + effectOptions?: CreateEffectOptions, +]; + +type EffectArgumentsWithoutBehavior = [ + callback: AnyEffectCallback, + effectOptions?: CreateEffectOptions, +]; + +type EffectArguments = + | EffectArgumentsWithBehavior + | EffectArgumentsWithoutBehavior; + +export function effect< + const Dependencies extends readonly EffectDependency[], + FilteredValues extends DependencyValues, + ReturnValue, +>( + dependencies: readonly [...Dependencies], + behaviorOptions: FilteredEffectBehaviorOptions, + callback: EffectCallback, + effectOptions?: CreateEffectOptions, +): EffectRef; +export function effect< + const Dependencies extends readonly EffectDependency[], + ReturnValue, +>( + dependencies: readonly [...Dependencies], + behaviorOptions: EffectBehaviorOptions, + callback: EffectCallback, ReturnValue>, + effectOptions?: CreateEffectOptions, +): EffectRef; +export function effect< + const Dependencies extends readonly EffectDependency[], + ReturnValue, +>( + dependencies: readonly [...Dependencies], + callback: EffectCallback, ReturnValue>, + effectOptions?: CreateEffectOptions, +): EffectRef; +export function effect( + dependencies: readonly EffectDependency[], + ...args: EffectArguments +): EffectRef { + const { behaviorOptions, callback, effectOptions } = + normalizeEffectArguments(args); + const { defer = false, once = false, filter } = behaviorOptions; + let shouldDefer = defer; + let previousValues = dependencies.map( + () => undefined, + ) as PreviousValues; + let previousReturnValue: unknown; + + const effectRef = angularEffect((onCleanup) => { + const values = dependencies.map((dependency) => + dependency(), + ) as RuntimeValues; + + if (shouldDefer) { + shouldDefer = false; + return; + } + + if (filter && !filter(values, previousValues)) { + return; + } + + previousReturnValue = untracked(() => { + return callback(values, previousValues, onCleanup, previousReturnValue); + }); + previousValues = values; + + if (once) { + effectRef.destroy(); + } + }, effectOptions); + + return effectRef; +} + +function normalizeEffectArguments(args: EffectArguments): { + behaviorOptions: EffectBehaviorOptions; + callback: RuntimeEffectCallback; + effectOptions?: CreateEffectOptions; +} { + if (typeof args[0] === 'function') { + const [callback, effectOptions] = args as EffectArgumentsWithoutBehavior; + + return { + behaviorOptions: {}, + callback, + effectOptions, + }; + } + + const [behaviorOptions, callback, effectOptions] = + args as EffectArgumentsWithBehavior; + + return { + behaviorOptions, + callback, + effectOptions, + }; +} diff --git a/libs/ngxtension/effect/src/index.ts b/libs/ngxtension/effect/src/index.ts new file mode 100644 index 000000000..9f0d90c01 --- /dev/null +++ b/libs/ngxtension/effect/src/index.ts @@ -0,0 +1 @@ +export * from './effect'; diff --git a/libs/ngxtension/explicit-effect/src/explicit-effect.spec.ts b/libs/ngxtension/explicit-effect/src/explicit-effect.spec.ts index 0c5bdaf59..5884db5a1 100644 --- a/libs/ngxtension/explicit-effect/src/explicit-effect.spec.ts +++ b/libs/ngxtension/explicit-effect/src/explicit-effect.spec.ts @@ -1,8 +1,8 @@ import { Component, computed, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { explicitEffect } from './explicit-effect'; +import { effect } from 'ngxtension/effect'; -describe(explicitEffect.name, () => { +describe('explicitEffect behavior with effect', () => { let log: string[] = []; let cleanupLog: string[] = []; @@ -20,9 +20,9 @@ describe(explicitEffect.name, () => { state = signal('idle'); foobar = signal<'foo' | 'bar'>('foo'); - eff = explicitEffect( + eff = effect( [this.count, this.state], - ([count, state], cleanUpFn) => { + ([count, state], _previousValues, cleanUpFn) => { this.foobar(); log.push(`count updated ${count}, ${state}`); @@ -43,7 +43,7 @@ describe(explicitEffect.name, () => { expect(log.length).toBe(2); }); - it('should not run when unresgistered dep', () => { + it('should not run when unregistered dep', () => { const fixture = TestBed.createComponent(Foo); fixture.detectChanges(); expect(log.length).toBe(1); @@ -76,7 +76,7 @@ describe(explicitEffect.name, () => { const result = () => count() + doubleCount() + foobar(); TestBed.runInInjectionContext(() => { - explicitEffect( + effect( [count, state, doubleCount, result], ([count, state, doubleCount, result]) => { log.push( @@ -100,7 +100,7 @@ describe(explicitEffect.name, () => { const state = signal('idle'); TestBed.runInInjectionContext(() => { - explicitEffect([count, state], ([count, state]) => { + effect([count, state], ([count, state]) => { const _count: number = count; const _state: string = state; console.log(_count, _state); @@ -114,12 +114,12 @@ describe(explicitEffect.name, () => { const state = signal('idle'); TestBed.runInInjectionContext(() => { - explicitEffect( + effect( [count, state], + { defer: true }, ([count, state]) => { log.push(`count updated ${count}, ${state}`); }, - { defer: true }, ); expect(log.length).toBe(0); TestBed.flushEffects(); diff --git a/libs/ngxtension/reactive-on/src/on.spec.ts b/libs/ngxtension/reactive-on/src/on.spec.ts index 8b810a9d4..14917c9a5 100644 --- a/libs/ngxtension/reactive-on/src/on.spec.ts +++ b/libs/ngxtension/reactive-on/src/on.spec.ts @@ -1,14 +1,8 @@ -import { - ApplicationRef, - Injector, - computed, - effect, - signal, -} from '@angular/core'; +import { ApplicationRef, Injector, computed, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { on } from './on'; +import { effect } from 'ngxtension/effect'; -describe(on.name, () => { +describe('on behavior with effect', () => { let injector: Injector; let appRef: ApplicationRef; @@ -22,9 +16,10 @@ describe(on.name, () => { const log: number[] = []; effect( - on(count, (c) => { + [count], + ([c]) => { log.push(c); - }), + }, { injector }, ); @@ -42,10 +37,11 @@ describe(on.name, () => { const log: number[] = []; effect( - on(count, (c) => { + [count], + ([c]) => { // accessing 'other' which is not in deps log.push(c + other()); - }), + }, { injector }, ); @@ -69,9 +65,10 @@ describe(on.name, () => { const log: number[] = []; effect( - on([a, b], ([valA, valB]) => { + [a, b], + ([valA, valB]) => { log.push(valA + valB); - }), + }, { injector }, ); @@ -93,9 +90,10 @@ describe(on.name, () => { const log: number[] = []; effect( - on({ a, b }, ({ a: valA, b: valB }) => { + [() => ({ a: a(), b: b() })], + ([{ a: valA, b: valB }]) => { log.push(valA * valB); - }), + }, { injector }, ); @@ -116,11 +114,12 @@ describe(on.name, () => { const log: string[] = []; effect( - on(count, (input, prevInput) => { + [count], + ([input], [prevInput]) => { const result = `cur: ${input}, prevIn: ${prevInput}`; log.push(result); return undefined; - }), + }, { injector }, ); @@ -137,12 +136,13 @@ describe(on.name, () => { const log: string[] = []; const effectRef = effect( - on(count, (c, _, __, onCleanup) => { + [count], + ([c], _previousValues, onCleanup) => { log.push(`run: ${c}`); onCleanup(() => { log.push(`cleanup: ${c}`); }); - }), + }, { injector }, ); @@ -162,11 +162,12 @@ describe(on.name, () => { const log: number[] = []; effect( - on(count, (c, _, prevValue) => { + [count], + ([c], _previousValues, _onCleanup, prevValue) => { const result = c + ((prevValue as number) || 0); log.push(result); return result; - }), + }, { injector }, ); @@ -188,9 +189,10 @@ describe(on.name, () => { const log: number[] = []; effect( - on(doubleCount, (val) => { + [doubleCount], + ([val]) => { log.push(val); - }), + }, { injector }, ); @@ -207,13 +209,11 @@ describe(on.name, () => { const log: number[] = []; effect( - on( - count, - (c) => { - log.push(c); - }, - { defer: true }, - ), + [count], + { defer: true }, + ([c]) => { + log.push(c); + }, { injector }, ); diff --git a/tsconfig.base.json b/tsconfig.base.json index b1ef10893..dd82fd4e1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -65,6 +65,7 @@ "libs/ngxtension/derived-async/src/index.ts" ], "ngxtension/derived-from": ["libs/ngxtension/derived-from/src/index.ts"], + "ngxtension/effect": ["libs/ngxtension/effect/src/index.ts"], "ngxtension/effect-once-if": [ "libs/ngxtension/effect-once-if/src/index.ts" ],