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
11 changes: 6 additions & 5 deletions libs/ngxtension/effect-once-if/src/effect-once-if.spec.ts
Original file line number Diff line number Diff line change
@@ -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[] = [];
Expand All @@ -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}`);
Expand All @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions libs/ngxtension/effect/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# ngxtension/effect

Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/effect`.
5 changes: 5 additions & 0 deletions libs/ngxtension/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/effect/project.json
Original file line number Diff line number Diff line change
@@ -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}"]
}
}
}
268 changes: 268 additions & 0 deletions libs/ngxtension/effect/src/effect.spec.ts
Original file line number Diff line number Diff line change
@@ -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<number | null>(1);
const second = signal<string | undefined>('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<number | null>(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);
});
});
Loading